Add minimal text layout API using taffy, cosmic-text, and wgpu

Introduces a public App/TextBox builder API that renders word-wrapped
ASCII text in taffy-laid-out rectangles via a wgpu GPU pipeline.

- App/TextBox builder API (width, height, font_size, text_color, background, padding)
- Taffy flex-column layout tree computed per frame
- cosmic-text shapes and rasterizes text to a CPU RGBA8 buffer each frame
- Two wgpu pipelines: rect (colored quads) and text (textured quads)
- Winit event loop wired through ApplicationHandler
This commit is contained in:
Greg Shuflin
2026-06-17 00:44:42 -07:00
parent a542ac1c91
commit 414d191cb7
7 changed files with 1173 additions and 276 deletions
Generated
+228 -3
View File
@@ -378,6 +378,39 @@ dependencies = [
"libc",
]
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "cosmic-text"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73"
dependencies = [
"bitflags 2.13.0",
"fontdb",
"harfrust",
"linebender_resource_handle",
"log",
"rangemap",
"rustc-hash 2.1.2",
"self_cell",
"skrifa 0.40.0",
"smol_str 0.3.6",
"swash",
"sys-locale",
"unicode-bidi",
"unicode-linebreak",
"unicode-script",
"unicode-segmentation",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -499,6 +532,38 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
dependencies = [
"bytemuck",
]
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -649,6 +714,8 @@ name = "gui-layout"
version = "0.1.0"
dependencies = [
"anyhow",
"bytemuck",
"cosmic-text",
"env_logger",
"log",
"pollster",
@@ -669,6 +736,19 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "harfrust"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9"
dependencies = [
"bitflags 2.13.0",
"bytemuck",
"core_maths",
"read-fonts 0.37.0",
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -877,6 +957,12 @@ dependencies = [
"redox_syscall 0.8.1",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -945,7 +1031,7 @@ dependencies = [
"log",
"num-traits",
"once_cell",
"rustc-hash",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.18",
"unicode-ident",
@@ -1474,6 +1560,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "rangemap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -1492,6 +1584,27 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "read-fonts"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5"
dependencies = [
"bytemuck",
"core_maths",
"font-types",
]
[[package]]
name = "read-fonts"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
dependencies = [
"bytemuck",
"font-types",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -1554,12 +1667,24 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -1635,6 +1760,12 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "self_cell"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
[[package]]
name = "semver"
version = "1.0.28"
@@ -1693,6 +1824,26 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "skrifa"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac"
dependencies = [
"bytemuck",
"read-fonts 0.37.0",
]
[[package]]
name = "skrifa"
version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
dependencies = [
"bytemuck",
"read-fonts 0.39.2",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -1748,6 +1899,12 @@ dependencies = [
"serde",
]
[[package]]
name = "smol_str"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
@@ -1769,6 +1926,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "swash"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c"
dependencies = [
"skrifa 0.42.1",
"yazi",
"zeno",
]
[[package]]
name = "syn"
version = "2.0.117"
@@ -1780,6 +1948,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "taffy"
version = "0.11.0"
@@ -1866,6 +2043,21 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -1917,6 +2109,15 @@ name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
@@ -1924,6 +2125,18 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -2203,7 +2416,7 @@ dependencies = [
"portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 2.0.18",
"wgpu-core-deps-apple",
@@ -2564,7 +2777,7 @@ dependencies = [
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit",
"smol_str",
"smol_str 0.2.2",
"tracing",
"unicode-segmentation",
"wasm-bindgen",
@@ -2659,6 +2872,18 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "yazi"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
[[package]]
name = "zeno"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
[[package]]
name = "zerocopy"
version = "0.8.52"
+2
View File
@@ -11,3 +11,5 @@ log = "0.4.32"
anyhow = "1.0.102"
pollster = "0.4.0"
taffy = "0.11.0"
cosmic-text = "0.19.0"
bytemuck = { version = "1.25.0", features = ["derive"] }
+508
View File
@@ -0,0 +1,508 @@
pub mod renderer;
use std::sync::Arc;
use cosmic_text::{Attrs, Buffer, Color as TextColor, FontSystem, Metrics, Shaping, SwashCache};
use taffy::prelude::*;
use wgpu::InstanceDescriptor;
use winit::{
application::ApplicationHandler,
event::*,
event_loop::{ActiveEventLoop, EventLoop},
keyboard::{KeyCode, PhysicalKey},
window::Window,
};
use renderer::{DrawRect, DrawText, Renderer};
/// A fixed-size box that renders word-wrapped text.
pub struct TextBox {
text: String,
width: Option<f32>,
height: Option<f32>,
font_size: f32,
text_color: [f32; 4],
bg_color: [f32; 4],
padding: f32,
}
impl TextBox {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
width: None,
height: None,
font_size: 16.0,
text_color: [1.0, 1.0, 1.0, 1.0],
bg_color: [0.15, 0.25, 0.35, 1.0],
padding: 8.0,
}
}
pub fn width(mut self, w: f32) -> Self {
self.width = Some(w);
self
}
pub fn height(mut self, h: f32) -> Self {
self.height = Some(h);
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.font_size = size;
self
}
pub fn text_color(mut self, color: [f32; 4]) -> Self {
self.text_color = color;
self
}
pub fn background(mut self, color: [f32; 4]) -> Self {
self.bg_color = color;
self
}
pub fn padding(mut self, p: f32) -> Self {
self.padding = p;
self
}
fn taffy_style(&self) -> taffy::Style {
taffy::Style {
size: Size {
width: match self.width {
Some(w) => Dimension::from_length(w),
None => Dimension::AUTO,
},
height: match self.height {
Some(h) => Dimension::from_length(h),
None => Dimension::AUTO,
},
},
..Default::default()
}
}
}
/// Top-level application builder. Chain `.child()` calls then call `.run()`.
pub struct App {
title: String,
bg_color: [f32; 4],
children: Vec<TextBox>,
}
impl App {
pub fn new() -> Self {
Self {
title: "App".to_string(),
bg_color: [0.08, 0.08, 0.10, 1.0],
children: Vec::new(),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn background(mut self, color: [f32; 4]) -> Self {
self.bg_color = color;
self
}
pub fn child(mut self, widget: TextBox) -> Self {
self.children.push(widget);
self
}
pub fn run(self) -> anyhow::Result<()> {
env_logger::try_init().ok();
let event_loop = EventLoop::new()?;
let mut state = AppState::new(self);
event_loop.run_app(&mut state)?;
Ok(())
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
// ── internal ──────────────────────────────────────────────────────────────────
struct WgpuState {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
surface_format: wgpu::TextureFormat,
is_configured: bool,
window: Arc<Window>,
}
struct AppState {
app: App,
wgpu: Option<WgpuState>,
renderer: Option<Renderer>,
font_system: FontSystem,
swash_cache: SwashCache,
}
impl AppState {
fn new(app: App) -> Self {
Self {
app,
wgpu: None,
renderer: None,
font_system: FontSystem::new(),
swash_cache: SwashCache::new(),
}
}
fn render(&mut self) {
// Phase 1: acquire surface texture (borrows self.wgpu mutably, then releases)
let (output, win_w, win_h) = {
let Some(wgpu) = &mut self.wgpu else { return };
if !wgpu.is_configured || self.renderer.is_none() {
return;
}
let tex = match wgpu.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(t) => t,
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
wgpu.surface.configure(&wgpu.device, &wgpu.config);
t
}
wgpu::CurrentSurfaceTexture::Outdated => {
wgpu.surface.configure(&wgpu.device, &wgpu.config);
return;
}
_ => return,
};
let w = wgpu.config.width;
let h = wgpu.config.height;
(tex, w, h)
}; // wgpu borrow released here
let view = output
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
// Phase 2: build draw lists (needs &mut self for font_system / swash_cache)
let (rects, texts) = self.build_draw_lists(win_w, win_h);
// Phase 3: render (immutable borrows of different fields)
let bg = self.app.bg_color;
let wgpu = self.wgpu.as_ref().unwrap();
let renderer = self.renderer.as_ref().unwrap();
renderer.render(
&wgpu.device,
&wgpu.queue,
&view,
bg,
win_w,
win_h,
&rects,
&texts,
);
output.present();
}
fn build_draw_lists(&mut self, win_w: u32, win_h: u32) -> (Vec<DrawRect>, Vec<DrawText>) {
let mut tree: TaffyTree<usize> = TaffyTree::new();
let child_ids: Vec<NodeId> = self
.app
.children
.iter()
.enumerate()
.map(|(i, w)| {
tree.new_leaf_with_context(w.taffy_style(), i)
.expect("taffy leaf")
})
.collect();
let root = tree
.new_with_children(
taffy::Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: Size {
width: Dimension::from_percent(1.0),
height: Dimension::from_percent(1.0),
},
..Default::default()
},
&child_ids,
)
.expect("taffy root");
tree.compute_layout(
root,
Size {
width: AvailableSpace::Definite(win_w as f32),
height: AvailableSpace::Definite(win_h as f32),
},
)
.expect("taffy compute");
// Collect layout + widget params (releasing borrows on self.app.children)
struct WidgetDraw {
rect: DrawRect,
text_x: f32,
text_y: f32,
inner_w: f32,
inner_h: f32,
text: String,
font_size: f32,
text_color: [f32; 4],
}
let widget_data: Vec<WidgetDraw> = self
.app
.children
.iter()
.enumerate()
.map(|(i, widget)| {
let layout = tree.layout(child_ids[i]).expect("layout");
let x = layout.location.x;
let y = layout.location.y;
let w = layout.size.width;
let h = layout.size.height;
let inner_w = (w - widget.padding * 2.0).max(0.0);
let inner_h = (h - widget.padding * 2.0).max(0.0);
WidgetDraw {
rect: DrawRect { x, y, width: w, height: h, color: widget.bg_color },
text_x: x + widget.padding,
text_y: y + widget.padding,
inner_w,
inner_h,
text: widget.text.clone(),
font_size: widget.font_size,
text_color: widget.text_color,
}
})
.collect();
// Now we can mutably borrow self.font_system / self.swash_cache
let mut rects = Vec::new();
let mut texts = Vec::new();
for wd in widget_data {
rects.push(wd.rect);
if wd.inner_w > 0.0 && wd.inner_h > 0.0 {
let pixels = self.rasterize_text(
&wd.text,
wd.font_size,
wd.text_color,
wd.inner_w,
wd.inner_h,
);
texts.push(DrawText {
x: wd.text_x,
y: wd.text_y,
width: wd.inner_w,
height: wd.inner_h,
pixels,
pixel_width: wd.inner_w as u32,
pixel_height: wd.inner_h as u32,
});
}
}
(rects, texts)
}
fn rasterize_text(
&mut self,
text: &str,
font_size: f32,
text_color_f: [f32; 4],
w: f32,
h: f32,
) -> Vec<u8> {
let tw = w as u32;
let th = h as u32;
let mut pixels = vec![0u8; (tw * th * 4) as usize];
let line_height = font_size * 1.25;
let mut buffer =
Buffer::new(&mut self.font_system, Metrics::new(font_size, line_height));
buffer.set_size(Some(w), Some(h));
buffer.set_text(text, &Attrs::new(), Shaping::Basic, None);
let tc = text_color_f;
let text_color = TextColor::rgba(
(tc[0] * 255.0) as u8,
(tc[1] * 255.0) as u8,
(tc[2] * 255.0) as u8,
(tc[3] * 255.0) as u8,
);
buffer.draw(
&mut self.font_system,
&mut self.swash_cache,
text_color,
|px, py, pw, ph, color| {
let a = color.a();
if a == 0 {
return;
}
for dy in 0..ph {
for dx in 0..pw {
let sx = px + dx as i32;
let sy = py + dy as i32;
if sx < 0 || sy < 0 || sx >= tw as i32 || sy >= th as i32 {
continue;
}
let base = ((sy as u32 * tw + sx as u32) * 4) as usize;
let fa = a as f32 / 255.0;
let inv = 1.0 - fa;
pixels[base] =
(pixels[base] as f32 * inv + color.r() as f32 * fa) as u8;
pixels[base + 1] =
(pixels[base + 1] as f32 * inv + color.g() as f32 * fa) as u8;
pixels[base + 2] =
(pixels[base + 2] as f32 * inv + color.b() as f32 * fa) as u8;
pixels[base + 3] =
(pixels[base + 3] as f32 * inv + fa * 255.0) as u8;
}
}
},
);
pixels
}
}
impl ApplicationHandler for AppState {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let mut attrs = Window::default_attributes();
attrs.title = self.app.title.clone();
let window = Arc::new(
event_loop
.create_window(attrs)
.expect("create window"),
);
let wgpu_state = pollster::block_on(async {
let instance = wgpu::Instance::new(InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
flags: Default::default(),
memory_budget_thresholds: Default::default(),
backend_options: Default::default(),
display: None,
});
let surface = instance
.create_surface(window.clone())
.expect("create surface");
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
force_fallback_adapter: false,
compatible_surface: Some(&surface),
})
.await
.expect("find adapter");
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::empty(),
experimental_features: wgpu::ExperimentalFeatures::disabled(),
required_limits: wgpu::Limits::default(),
memory_hints: Default::default(),
trace: wgpu::Trace::Off,
})
.await
.expect("create device");
let size = window.inner_size();
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: surface_caps.present_modes[0],
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
WgpuState {
surface,
device,
queue,
config,
surface_format,
is_configured: false,
window,
}
});
let renderer = Renderer::new(&wgpu_state.device, wgpu_state.surface_format);
self.renderer = Some(renderer);
self.wgpu = Some(wgpu_state);
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
event: WindowEvent,
) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::KeyboardInput {
event:
KeyEvent {
physical_key: PhysicalKey::Code(KeyCode::Escape),
state: ElementState::Pressed,
..
},
..
} => event_loop.exit(),
WindowEvent::Resized(size) => {
if let Some(wgpu) = &mut self.wgpu {
if size.width > 0 && size.height > 0 {
wgpu.config.width = size.width;
wgpu.config.height = size.height;
wgpu.surface.configure(&wgpu.device, &wgpu.config);
wgpu.is_configured = true;
}
}
if let Some(wgpu) = &self.wgpu {
wgpu.window.request_redraw();
}
}
WindowEvent::RedrawRequested => {
self.render();
}
_ => {}
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(wgpu) = &self.wgpu {
wgpu.window.request_redraw();
}
}
}
+28 -273
View File
@@ -1,276 +1,31 @@
use std::sync::Arc;
use wgpu::InstanceDescriptor;
use winit::{
application::ApplicationHandler,
event::*,
event_loop::{ActiveEventLoop, EventLoop},
keyboard::{KeyCode, PhysicalKey},
window::Window,
};
// This will store the state of our game
pub struct State {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
is_surface_configured: bool,
window: Arc<Window>,
}
impl State {
pub async fn new(window: Arc<Window>) -> anyhow::Result<Self> {
// main purpose of Instance is to create Adapter and Surface
let instance = wgpu::Instance::new(InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
flags: Default::default(),
memory_budget_thresholds: Default::default(),
backend_options: Default::default(),
display: None,
});
let surface = instance.create_surface(window.clone())?;
println!("Surface: {:?}", surface);
// list all adatpers
for item in instance.enumerate_adapters(wgpu::Backends::all()).await {
println!("Adapter: {:?}", item);
}
// handle for actual gfx card
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
})
.await?;
println!("ADAPTER: {:?}", adapter);
let surface_caps = surface.get_capabilities(&adapter);
// Shader code in this tutorial assumes an sRGB surface texture. Using a different
// one will result in all the colors coming out darker. If you want to support non
// sRGB surfaces, you'll need to account for that when drawing to the frame.
let surface_format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
let size = window.inner_size();
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: size.width,
height: size.height,
present_mode: surface_caps.present_modes[0],
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::empty(),
experimental_features: wgpu::ExperimentalFeatures::disabled(),
required_limits: wgpu::Limits::default(),
memory_hints: Default::default(),
trace: wgpu::Trace::Off,
})
.await?;
Ok(Self {
window,
surface,
device,
queue,
config,
is_surface_configured: false,
})
}
pub fn resize(&mut self, width: u32, height: u32) {
if width > 0 && height > 0 {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.is_surface_configured = true;
}
}
pub fn render(&mut self) -> anyhow::Result<()> {
self.window.request_redraw();
if !self.is_surface_configured {
return Ok(());
}
let output = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(surface_texture) => surface_texture,
wgpu::CurrentSurfaceTexture::Suboptimal(surface_texture) => {
self.surface.configure(&self.device, &self.config);
surface_texture
}
wgpu::CurrentSurfaceTexture::Timeout
| wgpu::CurrentSurfaceTexture::Occluded
| wgpu::CurrentSurfaceTexture::Validation => {
// Skip this frame
return Ok(());
}
wgpu::CurrentSurfaceTexture::Outdated => {
self.surface.configure(&self.device, &self.config);
return Ok(());
}
wgpu::CurrentSurfaceTexture::Lost => {
// You could recreate the devices and all resources
// created with it here, but we'll just bail
anyhow::bail!("Lost device");
}
};
let view = output
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Render Encoder"),
});
{
let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.1,
g: 0.2,
b: 0.3,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
multiview_mask: None,
});
}
// submit will accept anything that implements IntoIter
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
Ok(())
}
fn handle_key(&self, event_loop: &ActiveEventLoop, code: KeyCode, is_pressed: bool) {
match (code, is_pressed) {
(KeyCode::Escape, true) => event_loop.exit(),
_ => {}
}
}
fn handle_mouse(&self, element_state: ElementState, button: MouseButton) {
match button {
MouseButton::Left => {}
MouseButton::Right => {}
MouseButton::Middle => (),
MouseButton::Back => (),
MouseButton::Forward => (),
MouseButton::Other(_) => (),
}
}
}
pub struct App {
state: Option<State>,
}
impl App {
pub fn new() -> Self {
Self { state: None }
}
}
impl ApplicationHandler<State> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
#[allow(unused_mut)]
let mut window_attributes = Window::default_attributes();
let window = Arc::new(event_loop.create_window(window_attributes).unwrap());
self.state = Some(pollster::block_on(State::new(window)).unwrap());
}
#[allow(unused_mut)]
fn user_event(&mut self, _event_loop: &ActiveEventLoop, mut event: State) {
self.state = Some(event);
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
event: WindowEvent,
) {
use WindowEvent::*;
let state = match &mut self.state {
Some(state) => state,
None => return,
};
match event {
CloseRequested => event_loop.exit(),
Resized(size) => state.resize(size.width, size.height),
RedrawRequested => match state.render() {
Ok(_) => (),
Err(e) => {
log::error!("{e}");
event_loop.exit();
}
},
MouseInput {
device_id: _,
state: mouse_state,
button,
} => {
state.handle_mouse(mouse_state, button);
}
KeyboardInput {
event:
KeyEvent {
physical_key: PhysicalKey::Code(code),
state: key_state,
..
},
..
} => state.handle_key(event_loop, code, key_state.is_pressed()),
_ => {}
}
}
}
pub fn run() -> anyhow::Result<()> {
env_logger::init();
let event_loop = EventLoop::with_user_event().build()?;
let mut app = App::new();
event_loop.run_app(&mut app)?;
Ok(())
}
use gui_layout::{App, TextBox};
fn main() {
run().unwrap();
App::new()
.title("gui-layout demo")
.background([0.08, 0.08, 0.10, 1.0])
.child(
TextBox::new(
"Hello! This is a wrapped text box powered by cosmic-text and laid out \
with taffy. The text will wrap at the box boundary. You can resize the \
window and the layout will reflow.",
)
.width(400.0)
.height(200.0)
.font_size(16.0)
.text_color([1.0, 1.0, 1.0, 1.0])
.background([0.15, 0.25, 0.40, 1.0])
.padding(12.0),
)
.child(
TextBox::new("A second box with smaller text and a different color.")
.width(400.0)
.height(80.0)
.font_size(13.0)
.text_color([0.9, 0.85, 0.6, 1.0])
.background([0.25, 0.15, 0.15, 1.0])
.padding(12.0),
)
.run()
.unwrap();
}
+358
View File
@@ -0,0 +1,358 @@
use std::mem;
use wgpu::util::DeviceExt;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct RectVertex {
position: [f32; 2],
color: [f32; 4],
}
impl RectVertex {
const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: mem::size_of::<Self>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4],
};
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct TextVertex {
position: [f32; 2],
uv: [f32; 2],
}
impl TextVertex {
const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: mem::size_of::<Self>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2],
};
}
pub struct DrawRect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub color: [f32; 4],
}
pub struct DrawText {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
/// RGBA8 pixel data, row-major
pub pixels: Vec<u8>,
pub pixel_width: u32,
pub pixel_height: u32,
}
pub struct Renderer {
rect_pipeline: wgpu::RenderPipeline,
text_pipeline: wgpu::RenderPipeline,
text_bind_group_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
}
impl Renderer {
pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
let rect_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("rect_shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/rect.wgsl").into()),
});
let rect_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("rect_pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &rect_shader,
entry_point: Some("vs_main"),
buffers: &[RectVertex::LAYOUT],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &rect_shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: surface_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let text_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("text_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let text_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("text_pipeline_layout"),
bind_group_layouts: &[Some(&text_bind_group_layout)],
immediate_size: 0,
});
let text_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("text_shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/text.wgsl").into()),
});
let text_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("text_pipeline"),
layout: Some(&text_pipeline_layout),
vertex: wgpu::VertexState {
module: &text_shader,
entry_point: Some("vs_main"),
buffers: &[TextVertex::LAYOUT],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &text_shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: surface_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("text_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
rect_pipeline,
text_pipeline,
text_bind_group_layout,
sampler,
}
}
fn to_ndc(px: f32, py: f32, win_w: u32, win_h: u32) -> [f32; 2] {
[
px / win_w as f32 * 2.0 - 1.0,
1.0 - py / win_h as f32 * 2.0,
]
}
fn rect_quad(r: &DrawRect, win_w: u32, win_h: u32) -> [RectVertex; 6] {
let c = r.color;
let tl = Self::to_ndc(r.x, r.y, win_w, win_h);
let tr = Self::to_ndc(r.x + r.width, r.y, win_w, win_h);
let bl = Self::to_ndc(r.x, r.y + r.height, win_w, win_h);
let br = Self::to_ndc(r.x + r.width, r.y + r.height, win_w, win_h);
[
RectVertex { position: tl, color: c },
RectVertex { position: tr, color: c },
RectVertex { position: bl, color: c },
RectVertex { position: tr, color: c },
RectVertex { position: br, color: c },
RectVertex { position: bl, color: c },
]
}
fn text_quad(t: &DrawText, win_w: u32, win_h: u32) -> [TextVertex; 6] {
let tl = Self::to_ndc(t.x, t.y, win_w, win_h);
let tr = Self::to_ndc(t.x + t.width, t.y, win_w, win_h);
let bl = Self::to_ndc(t.x, t.y + t.height, win_w, win_h);
let br = Self::to_ndc(t.x + t.width, t.y + t.height, win_w, win_h);
[
TextVertex { position: tl, uv: [0.0, 0.0] },
TextVertex { position: tr, uv: [1.0, 0.0] },
TextVertex { position: bl, uv: [0.0, 1.0] },
TextVertex { position: tr, uv: [1.0, 0.0] },
TextVertex { position: br, uv: [1.0, 1.0] },
TextVertex { position: bl, uv: [0.0, 1.0] },
]
}
pub fn render(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
view: &wgpu::TextureView,
bg_color: [f32; 4],
win_w: u32,
win_h: u32,
rects: &[DrawRect],
texts: &[DrawText],
) {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frame_encoder"),
});
let mut rect_verts: Vec<RectVertex> = Vec::new();
for r in rects {
rect_verts.extend_from_slice(&Self::rect_quad(r, win_w, win_h));
}
// Build text textures + bind groups (kept alive through submit)
let text_data: Vec<(wgpu::Texture, wgpu::TextureView, wgpu::BindGroup, [TextVertex; 6])> =
texts
.iter()
.filter(|t| t.pixel_width > 0 && t.pixel_height > 0)
.map(|t| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("text_texture"),
size: wgpu::Extent3d {
width: t.pixel_width,
height: t.pixel_height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&t.pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(t.pixel_width * 4),
rows_per_image: Some(t.pixel_height),
},
wgpu::Extent3d {
width: t.pixel_width,
height: t.pixel_height,
depth_or_array_layers: 1,
},
);
let tex_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("text_bg"),
layout: &self.text_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&tex_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let quad = Self::text_quad(t, win_w, win_h);
(texture, tex_view, bind_group, quad)
})
.collect();
let rect_buf = if rect_verts.is_empty() {
None
} else {
Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("rect_vbuf"),
contents: bytemuck::cast_slice(&rect_verts),
usage: wgpu::BufferUsages::VERTEX,
}))
};
let text_vbufs: Vec<wgpu::Buffer> = text_data
.iter()
.map(|(_, _, _, quad)| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("text_vbuf"),
contents: bytemuck::cast_slice(quad.as_slice()),
usage: wgpu::BufferUsages::VERTEX,
})
})
.collect();
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: bg_color[0] as f64,
g: bg_color[1] as f64,
b: bg_color[2] as f64,
a: bg_color[3] as f64,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
multiview_mask: None,
});
if let Some(buf) = &rect_buf {
pass.set_pipeline(&self.rect_pipeline);
pass.set_vertex_buffer(0, buf.slice(..));
pass.draw(0..rect_verts.len() as u32, 0..1);
}
pass.set_pipeline(&self.text_pipeline);
for (i, (_, _, bind_group, _)) in text_data.iter().enumerate() {
pass.set_bind_group(0, bind_group, &[]);
pass.set_vertex_buffer(0, text_vbufs[i].slice(..));
pass.draw(0..6, 0..1);
}
}
queue.submit(std::iter::once(encoder.finish()));
}
}
+22
View File
@@ -0,0 +1,22 @@
struct VertexInput {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
}
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(in.position, 0.0, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color;
}
+27
View File
@@ -0,0 +1,27 @@
struct VertexInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@group(0) @binding(0)
var t_text: texture_2d<f32>;
@group(0) @binding(1)
var s_text: sampler;
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(in.position, 0.0, 1.0);
out.uv = in.uv;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(t_text, s_text, in.uv);
}