Skip to content

Instantly share code, notes, and snippets.

@maurges
Created January 7, 2026 20:49
Show Gist options
  • Select an option

  • Save maurges/8394770dca43ee070ab6fc8f9dfc1caa to your computer and use it in GitHub Desktop.

Select an option

Save maurges/8394770dca43ee070ab6fc8f9dfc1caa to your computer and use it in GitHub Desktop.
Use slint+parley+swash+zeno to render a text, and know when the page ends
[package]
name = "book-pages-slint"
version = "0.1.0"
edition = "2024"
[dependencies]
slint = { version = "1.14.1", default-features = false, features = [ "compat-1-2", "std", "gettext", "accessibility", "backend-winit", "renderer-software" ] }
parley = "0.7.0" # text shaping
swash = "0.2.6" # font introspection
zeno = "0.3.3" # 2d rasterization
# Uses fontconfig-sys with dlopen feature. Setting this feature /changes api/, and it's set by slint, so without this, fontique doesn't build, because the api is different
fontique = { version = "*", features = ["fontconfig-dlopen"] }
[profile.dev.package]
slint.opt-level = 3
parley.opt-level = 3
swash.opt-level = 3
zeno.opt-level = 3
mod render_text;
slint::slint! {
import { Button } from "std-widgets.slint";
export component MainWindow inherits Window {
in-out property <image> line_image;
out property <int> requested-texture-width: image.width / 1phx;
out property <int> requested-texture-height: image.height / 1phx;
callback cycle_color();
callback size_changed(int, int);
VerticalLayout {
image := Image {
source: line_image;
image-fit: fill;
changed width => { size_changed(self.width / 1phx, self.height / 1phx); }
changed height => { size_changed(self.width / 1phx, self.height / 1phx); }
}
Button {
text: "Cycle Color";
clicked => { cycle_color(); }
}
Text {
text: "Ты пидор / とっとと失せろ / 너희 엄마 덩치가 너무 커";
}
}
}
}
fn main() {
let app = MainWindow::new().unwrap();
// State to track current color
let color_state = std::rc::Rc::new(std::cell::RefCell::new(0u8));
let render_context = render_text::Context {
font: parley::FontContext::new(),
layout: parley::LayoutContext::new(),
scale: swash::scale::ScaleContext::new(),
};
let render_context = std::rc::Rc::new(std::cell::RefCell::new(render_context));
// Initial image with black line
//let initial_image = create_line_image(800, 600, [0, 0, 0]);
let initial_image = render_text::render_image(800, 600, [0, 0, 0], &mut render_context.borrow_mut());
app.set_line_image(initial_image);
let app_weak = app.as_weak();
let color_state_ = color_state.clone();
let render_context_ = render_context.clone();
app.on_size_changed(move |width, height| {
let state = color_state_.borrow();
let app = app_weak.upgrade().unwrap();
let new_image = render_text::render_image(
width as u32,
height as u32,
[*state, 0, 0],
&mut render_context_.borrow_mut(),
);
app.set_line_image(new_image);
});
let app_weak = app.as_weak();
app.on_cycle_color(move || {
let mut state = color_state.borrow_mut();
*state = !*state;
let app = app_weak.upgrade().unwrap();
let new_image = render_text::render_image(
app.get_requested_texture_width() as u32,
app.get_requested_texture_height() as u32,
[*state, 0, 0],
&mut render_context.borrow_mut(),
);
app.set_line_image(new_image);
});
app.run().unwrap();
}
/// Long-lived struct, probably alive for the whole app duration
pub struct Context {
pub font: parley::FontContext,
pub layout: parley::LayoutContext<ColorBrush>,
pub scale: swash::scale::ScaleContext,
}
// Create an image from text, text given as CONTENT constant for now
pub fn render_image(width: u32, height: u32, color: [u8; 3], cx: &mut Context) -> slint::Image {
let mut shared_buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
// memset to 255 - all white
{
let buffer = shared_buffer.make_mut_bytes();
buffer.fill(255);
}
let display_scale = 1.0; // shouldn't this be inferred somehow?
// Whether to automatically align to pixel boundaries, to avoid blurry text.
// When does one not want to use this then?
let quantize = true;
let margin = 10;
let text_color = slint::Rgba8Pixel { r: color[0], g: color[1], b: color[2], a: 255 };
let text_brush = ColorBrush { color: text_color };
let brush_style = parley::StyleProperty::Brush(text_brush);
let font_stack = parley::FontStack::from("system-ui");
let bold_style = parley::StyleProperty::FontWeight(parley::FontWeight::new(600.0));
// Add some styles. Styles are given one after another
let mut layout = {
// Here content is only used to check for length, according to sources
let mut builder = cx.layout.ranged_builder(&mut cx.font, CONTENT, display_scale, quantize);
// set default text color styles (fg)
builder.push_default(brush_style);
// set default font family
builder.push_default(font_stack);
builder.push_default(parley::LineHeight::FontSizeRelative(1.3));
builder.push_default(parley::StyleProperty::FontSize(16.0));
// set the first four characters to bold
builder.push(bold_style, 0..4);
let r: parley::Layout<ColorBrush> = builder.build(&CONTENT);
r
};
// Max width of the line
let advance = (width - margin * 2) as f32;
// break lines until we fit height
let max_height = (height - margin * 2) as f64;
let mut lines = layout.break_lines();
while let Some(_) = lines.break_next(advance) {
if lines.committed_y() >= max_height {
lines.revert();
break;
}
}
lines.finish();
// get range
let chunk_end = layout.get(layout.len() - 1).unwrap().text_range().end;
eprintln!("broken until {chunk_end}");
// align and justify. If not called, will justify to the left
layout.align(Some(advance), parley::Alignment::Justify, parley::AlignmentOptions::default());
let stride = shared_buffer.width();
let buffer = shared_buffer.make_mut_slice();
// iterate laid out lines
for line in layout.lines() {
// iterate glyph runs in each line
for item in line.items() {
match item {
parley::PositionedLayoutItem::GlyphRun(glyph_run) => {
render_glyph_run(buffer, stride, &mut cx.scale, &glyph_run, margin)
}
parley::PositionedLayoutItem::InlineBox(_) => {
// boxes are filled with black in the original, ehh ignore
// it
}
}
}
}
slint::Image::from_rgba8(shared_buffer)
}
/// Newtype wrapper for parley with a new default method
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ColorBrush {
pub color: slint::Rgba8Pixel,
}
impl Default for ColorBrush {
fn default() -> Self {
Self {
color: slint::Rgba8Pixel{ r: 0, g: 0, b: 0, a: 255 },
}
}
}
/// Render a collection of glyphs to an image. The glyphs are likely produced by
/// a layout algorithm
///
/// Doesn't check that glyphs can fit in height
fn render_glyph_run(
img: &mut [slint::Rgba8Pixel],
stride: u32,
context: &mut swash::scale::ScaleContext,
glyph_run: &parley::GlyphRun<'_, ColorBrush>,
margin: u32,
) {
// Resolve properties of the GlyphRun
let mut run_x = glyph_run.offset();
let run_y = glyph_run.baseline();
let style = glyph_run.style();
let color = style.brush;
// Get the "Run" from the "GlyphRun"
let run = glyph_run.run();
// Resolve properties of the Run
let font = run.font();
let font_size = run.font_size();
let normalized_coords = run.normalized_coords();
// Convert from parley::Font to swash::FontRef
let font_ref = swash::FontRef::from_index(font.data.as_ref(), font.index as usize).unwrap();
// Build a scaler. As the font properties are constant across an entire run of glyphs
// we can build one scaler for the run and reuse it for each glyph.
let mut scaler = context
.builder(font_ref)
.size(font_size)
.hint(true)
.normalized_coords(normalized_coords)
.build();
// Iterates over the glyphs in the GlyphRun
for glyph in glyph_run.glyphs() {
let glyph_x = run_x + glyph.x + (margin as f32);
let glyph_y = run_y - glyph.y + (margin as f32);
run_x += glyph.advance;
render_glyph(img, stride, &mut scaler, color, glyph, glyph_x, glyph_y);
}
// Draw decorations: underline & strikethrough
let style = glyph_run.style();
let run_metrics = run.metrics();
if let Some(decoration) = &style.underline {
let offset = decoration.offset.unwrap_or(run_metrics.underline_offset);
let size = decoration.size.unwrap_or(run_metrics.underline_size);
render_decoration(img, stride, glyph_run, decoration.brush, offset, size, margin);
}
if let Some(decoration) = &style.strikethrough {
let offset = decoration
.offset
.unwrap_or(run_metrics.strikethrough_offset);
let size = decoration.size.unwrap_or(run_metrics.strikethrough_size);
render_decoration(img, stride, glyph_run, decoration.brush, offset, size, margin);
}
}
// Render a line around text, like an underline or strikethrough. The text is
// given iwth glyph_run, the line is offset from baseline by `baseline`
fn render_decoration(
img: &mut [slint::Rgba8Pixel],
stride: u32,
glyph_run: &parley::GlyphRun<'_, ColorBrush>,
brush: ColorBrush,
offset: f32,
width: f32,
margin: u32,
) {
let y = glyph_run.baseline() - offset;
for pixel_y in y as u32..(y + width) as u32 {
for pixel_x in glyph_run.offset() as u32..(glyph_run.offset() + glyph_run.advance()) as u32
{
blend_image_pixel(img, pixel_x + margin, pixel_y + margin, stride, brush.color);
}
}
}
// Draw a single glyph to the image
fn render_glyph(
img: &mut [slint::Rgba8Pixel],
stride: u32,
scaler: &mut swash::scale::Scaler<'_>,
brush: ColorBrush,
glyph: parley::Glyph,
glyph_x: f32,
glyph_y: f32,
) {
// Compute the fractional offset
// You'll likely want to quantize this in a real renderer
let offset = zeno::Vector::new(glyph_x.fract(), glyph_y.fract());
// Render the glyph using swash
let rendered_glyph = swash::scale::Render::new(
// Select our source order
&[
swash::scale::Source::ColorOutline(0),
swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
swash::scale::Source::Outline,
],
)
// Select the simple alpha (non-subpixel) format
.format(zeno::Format::Alpha)
// Apply the fractional offset
.offset(offset)
// Render the image
.render(scaler, glyph.id as u16)
.unwrap();
let glyph_width = rendered_glyph.placement.width;
let glyph_height = rendered_glyph.placement.height;
let glyph_x = (glyph_x.floor() as i32 + rendered_glyph.placement.left) as u32;
let glyph_y = (glyph_y.floor() as i32 - rendered_glyph.placement.top) as u32;
match rendered_glyph.content {
swash::scale::image::Content::Mask => {
let mut i = 0;
let bc = brush.color;
for pixel_y in 0..glyph_height {
for pixel_x in 0..glyph_width {
let x = glyph_x + pixel_x;
let y = glyph_y + pixel_y;
let alpha = rendered_glyph.data[i];
let color = slint::Rgba8Pixel{ a: alpha, ..bc };
blend_image_pixel(img, x, y, stride, color);
i += 1;
}
}
}
swash::scale::image::Content::SubpixelMask => unimplemented!(),
swash::scale::image::Content::Color => {
let row_size = glyph_width as usize * 4;
for (pixel_y, row) in rendered_glyph.data.chunks_exact(row_size).enumerate() {
for (pixel_x, pixel) in row.chunks_exact(4).enumerate() {
let x = glyph_x + pixel_x as u32;
let y = glyph_y + pixel_y as u32;
let color = slint::Rgba8Pixel{ r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] };
blend_image_pixel(img, x, y, stride, color);
}
}
}
}
}
fn blend_image_pixel(pixels: &mut [slint::Rgba8Pixel], x: u32, y: u32, stride: u32, fg: slint::Rgba8Pixel) {
blend_into(&mut pixels[(y * stride + x) as usize], fg)
}
/// Blend a pixel into another pixel
fn blend_into(bg: &mut slint::Rgba8Pixel, fg: slint::Rgba8Pixel) {
// Adapted from `image` crate
// http://stackoverflow.com/questions/7438263/alpha-compositing-algorithm-blend-modes#answer-11163848
if fg.a == 0 {
return;
}
if fg.a == u8::MAX {
*bg = fg;
return;
}
// First, as we don't know what type our pixel is, we have to convert to floats between 0.0 and 1.0
let max_t = u8::MAX;
let max_t = f32::from(max_t);
let (bg_r, bg_g, bg_b, bg_a) = (
f32::from(bg.r) / max_t,
f32::from(bg.g) / max_t,
f32::from(bg.b) / max_t,
f32::from(bg.a) / max_t,
);
let (fg_r, fg_g, fg_b, fg_a) = (
f32::from(fg.r) / max_t,
f32::from(fg.g) / max_t,
f32::from(fg.b) / max_t,
f32::from(fg.a) / max_t,
);
// Work out what the final alpha level will be
let alpha_final = bg_a + fg_a - bg_a * fg_a;
if alpha_final == 0.0 {
return;
};
// We premultiply our channels by their alpha, as this makes it easier to calculate
let (bg_r_a, bg_g_a, bg_b_a) = (bg_r * bg_a, bg_g * bg_a, bg_b * bg_a);
let (fg_r_a, fg_g_a, fg_b_a) = (fg_r * fg_a, fg_g * fg_a, fg_b * fg_a);
// Standard formula for src-over alpha compositing
let (out_r_a, out_g_a, out_b_a) = (
fg_r_a + bg_r_a * (1.0 - fg_a),
fg_g_a + bg_g_a * (1.0 - fg_a),
fg_b_a + bg_b_a * (1.0 - fg_a),
);
// Unmultiply the channels by our resultant alpha channel
let (out_r, out_g, out_b) = (
out_r_a / alpha_final,
out_g_a / alpha_final,
out_b_a / alpha_final,
);
// Cast back to our initial type on return
*bg = slint::Rgba8Pixel {
r: (max_t * out_r) as u8,
g: (max_t * out_g) as u8,
b: (max_t * out_b) as u8,
a: (max_t * alpha_final) as u8,
};
}
const CONTENT: &'static str = "The appearance of the heroine in gleaming power armor had brought the room to a hush. The silence only allowed Dragon’s words to carry, bouncing off the hard floor, reaching the assembled students and staff of Arcadia High.
A low murmur ran through the room like an almost imperceptible aftershock, informing anyone and everyone who hadn’t been in earshot.
I could see Emma too, or I could see glimpses of her, between the students that were backing away from the front of the room. Already pale in complexion, she was white, now, staring.
I exhaled slowly, though my heart was pounding as if I’d just finished a hard run.
Defiant advanced a step, with the door to the kitchens behind him, while I took a few steps back toward the rest of the cafeteria, putting both Dragon and Defiant in front of me. Some of my bugs flowed in through the gaps around the door he’d rammed through. He’d slammed it shut behind him, but the metal had twisted around the lock, giving smaller bugs a path.
He slammed his spear against the ground. The entire cafeteria flinched at the crackle of electricity that ripped through the air around him, flowing along exposed pipe and the heating ducts in a path to the door. Every bug in the hallway died.
No use bringing bugs in that way.
I looked around me. This wasn’t an optimal battlefield. There were counters all around me, limiting my mobility, while barely impacting theirs. Someone had signaled Kid Win, Clockblocker and Adamant. The three heroes were heading our way. Sere remained tied up outside.
Five capes against me. With the bugs that had flowed into the building with Kid Win, I had maybe a thousand flying insects and some spiders. Not nearly enough to mount an offensive. I had neither a weapon nor swarm to give me an edge. I didn’t have my costume, either, but that wasn’t liable to matter.
Once upon a time, I’d had trouble getting my head around what Grue had been saying about reputation, about image and conveying the right impressions. Now it was all I had.
I let out another slow breath. Calm down. I rolled my shoulders, letting the kinks out. There was something almost relieving about the idea that things couldn’t get much worse than they were right now. Let the tension drain out. If they decided to drag me off to jail or the Birdcage, there wasn’t anything I could do about it.
They weren’t attacking. Maybe it wasn’t as bad as I thought. Were they not here to arrest me, or were they covering major routes my bugs might travel, to minimize my offensive strength?
Or did I have leverage I wasn’t accounting for?
I backed up until I’d reached a counter, then hopped up onto the edge, tucking one leg under me. It was a vantage point that gave me the ability to look directly at Dragon, with Defiant at the far left of my field of vision and many of the students to my right, Emma included.
“Low blow, Dragon,” I said, finally. “Outing me? I thought you were better than that.”";
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment