Skip to main content

max / shop

Cell-aligned render + HiDPI scale + smaller default font Three fixes together because they touch the same surface: - Cell-aligned batching. shop-render gets TextRenderer::draw_cells and a CellDraw record; each char sits at its passed-in cell coordinate rather than following per-glyph font advance. Fixes prompts whose starship nerd-font icons had non-cell-width advances and shifted everything after them (visible symptom: '~/Code/s' eaten from the path prefix). - HiDPI integer scale. Track scale from CompositorHandler::scale_factor_ changed, render buffer at physical size (logical * scale), call wl_surface.set_buffer_scale(N), rebuild TextRenderer at FONT_PX * scale so glyph rasterization is done at physical resolution. - Font size + padding. FONT_PX 18 -> 14, cell dims to match, PAD_X/Y for ink-stroke breathing room against the window edge. Fractional-scale-v1 not wired yet — if the compositor only reports fractional (no integer scale event), we still fall through at 1x. Add when someone hits it.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 19:49 UTC
Signed with PGP, not checked
Commit: a0f232572d51f5d336e89a7f1ec9782e31a50eb9
Parent: 7125a19
4 files changed, +203 insertions, -37 deletions
@@ -12,4 +12,4 @@
12 12 mod pipeline;
13 13 mod shaper;
14 14
15 - pub use pipeline::TextRenderer;
15 + pub use pipeline::{CellDraw, TextRenderer};
@@ -11,6 +11,16 @@
11 11 shaper::Shaper,
12 12 };
13 13
14 + /// One character with its cell-anchored position and color.
15 + #[derive(Debug, Clone, Copy)]
16 + pub struct CellDraw {
17 + pub c: char,
18 + /// Top-left of the cell, in pixels.
19 + pub x: f32,
20 + pub y: f32,
21 + pub color: [f32; 4],
22 + }
23 +
14 24 const ATLAS_SIZE: u32 = 1024;
15 25
16 26 #[repr(C)]
@@ -257,6 +267,93 @@
257 267 })
258 268 }
259 269
270 + /// Draw a batch of cell-anchored characters in one instanced draw call.
271 + /// Uses the font's charmap for glyph lookup (no shaping), so ligatures do
272 + /// not activate and per-glyph advance is ignored — each character sits at
273 + /// its passed-in cell coordinate. Missing glyphs are silently skipped.
274 + pub fn draw_cells(
275 + &mut self,
276 + device: &wgpu::Device,
277 + queue: &wgpu::Queue,
278 + view: &wgpu::TextureView,
279 + encoder: &mut wgpu::CommandEncoder,
280 + cells: &[CellDraw],
281 + ) {
282 + let ascent = self.shaper.ascent();
283 + let mut instances: Vec<Instance> = Vec::with_capacity(cells.len());
284 +
285 + for cell in cells {
286 + let glyph_id = self.shaper.glyph_id_for(cell.c);
287 + let cached = match self.cache.get(&glyph_id).copied() {
288 + Some(v) => v,
289 + None => {
290 + let entry = self.shaper.rasterize(glyph_id).and_then(|r| {
291 + self.atlas
292 + .upload(queue, r.width, r.height, &r.bitmap)
293 + .map(|slot| CachedGlyph {
294 + slot,
295 + left: r.placement_left,
296 + top: r.placement_top,
297 + })
298 + });
299 + self.cache.insert(glyph_id, entry);
300 + entry
301 + }
302 + };
303 + if let Some(c) = cached
304 + && c.slot.px[0] > 0
305 + && c.slot.px[1] > 0
306 + {
307 + let gx = cell.x + c.left as f32;
308 + let gy = cell.y + ascent - c.top as f32;
309 + instances.push(Instance {
310 + pos: [gx, gy],
311 + size: [c.slot.px[0] as f32, c.slot.px[1] as f32],
312 + uv_min: c.slot.uv_min,
313 + uv_max: c.slot.uv_max,
314 + color: cell.color,
315 + });
316 + }
317 + }
318 +
319 + if instances.is_empty() {
320 + return;
321 + }
322 +
323 + let needed = instances.len() as u64;
324 + if needed > self.instance_cap {
325 + let new_cap = needed.next_power_of_two();
326 + self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
327 + label: Some("shop.text.instances"),
328 + size: new_cap * std::mem::size_of::<Instance>() as u64,
329 + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
330 + mapped_at_creation: false,
331 + });
332 + self.instance_cap = new_cap;
333 + }
334 + queue.write_buffer(&self.instance_buf, 0, bytemuck::cast_slice(&instances));
335 +
336 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
337 + label: Some("shop.text.cells"),
338 + color_attachments: &[Some(wgpu::RenderPassColorAttachment {
339 + view,
340 + resolve_target: None,
341 + ops: wgpu::Operations {
342 + load: wgpu::LoadOp::Load,
343 + store: wgpu::StoreOp::Store,
344 + },
345 + depth_slice: None,
346 + })],
347 + ..Default::default()
348 + });
349 + pass.set_pipeline(&self.pipeline);
350 + pass.set_bind_group(0, &self.bind_group, &[]);
351 + pass.set_vertex_buffer(0, self.quad_buf.slice(..));
352 + pass.set_vertex_buffer(1, self.instance_buf.slice(..));
353 + pass.set_index_buffer(self.index_buf.slice(..), wgpu::IndexFormat::Uint16);
354 + pass.draw_indexed(0..6, 0, 0..instances.len() as u32);
355 + }
356 +
260 357 pub fn resize(&self, queue: &wgpu::Queue, width: u32, height: u32) {
261 358 let u = Uniforms {
262 359 screen: [width as f32, height as f32],
@@ -101,4 +101,15 @@
101 101 let metrics = font.metrics(&[]).scale(self.px);
102 102 metrics.ascent
103 103 }
104 +
105 + /// Char → glyph id via the font's char map. No shaping; used for cell-
106 + /// aligned monospace layout where per-glyph advance is not consulted.
107 + pub(crate) fn glyph_id_for(&self, c: char) -> GlyphId {
108 + let font = FontRef {
109 + data: &self.font_data,
110 + offset: self.font_offset,
111 + key: swash::CacheKey::new(),
112 + };
113 + font.charmap().map(c)
114 + }
104 115 }
@@ -14,7 +14,7 @@
14 14 use calloop_wayland_source::WaylandSource;
15 15 use shop_grid::Grid;
16 16 use shop_pty::{Pty, PtySize};
17 - use shop_render::TextRenderer;
17 + use shop_render::{CellDraw, TextRenderer};
18 18 use shop_wayland::{
19 19 Capability, CompositorHandler, CompositorState, Connection, OutputHandler, OutputState,
20 20 Pending, ProvidesRegistryState, QueueHandle, RegistryState, SeatHandler, SeatState,
@@ -28,9 +28,11 @@
28 28
29 29 const INITIAL: (u32, u32) = (960, 540);
30 30 const FONT_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf";
31 - const FONT_PX: f32 = 18.0;
32 - const CELL_ADVANCE: f32 = 11.0;
33 - const CELL_HEIGHT: f32 = 22.0;
31 + const FONT_PX: f32 = 14.0;
32 + const CELL_ADVANCE: f32 = 8.5;
33 + const CELL_HEIGHT: f32 = 17.0;
34 + const PAD_X: f32 = 10.0;
35 + const PAD_Y: f32 = 6.0;
34 36 const CLEAR: wgpu::Color = wgpu::Color {
35 37 r: 0.043,
36 38 g: 0.055,
@@ -51,8 +53,8 @@
51 53 .map_err(|e| anyhow::anyhow!("read font {FONT_PATH}: {e}"))?;
52 54
53 55 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into());
54 - let cols_initial: u16 = (INITIAL.0 as f32 / CELL_ADVANCE) as u16;
55 - let rows_initial: u16 = (INITIAL.1 as f32 / CELL_HEIGHT) as u16;
56 + let cols_initial = grid_cols(INITIAL.0);
57 + let rows_initial = grid_rows(INITIAL.1);
56 58 let pty = Pty::spawn(
57 59 &shell,
58 60 &[],
@@ -143,12 +145,15 @@
143 145 chrome,
144 146 surface,
145 147 surface_config,
148 + surface_format: format,
146 149 device: Arc::new(device),
147 150 queue: Arc::new(queue),
148 151 text,
149 152 pty,
150 153 grid,
151 154 parser,
155 + font_data: std::fs::read(FONT_PATH)?,
156 + scale: 1,
152 157 pending_redraw: true,
153 158 };
154 159 app.surface.configure(&app.device, &app.surface_config);
@@ -202,19 +207,27 @@
202 207
203 208 for change in app.chrome.pending.drain(..).collect::<Vec<_>>() {
204 209 match change {
205 - Pending::Resized { width, height } => {
206 - app.surface_config.width = width;
207 - app.surface_config.height = height;
210 + Pending::Resized {
211 + width: logical_w,
212 + height: logical_h,
213 + } => {
214 + let s = app.scale.max(1);
215 + let physical_w = logical_w * s;
216 + let physical_h = logical_h * s;
217 + app.surface_config.width = physical_w;
218 + app.surface_config.height = physical_h;
208 219 app.surface.configure(&app.device, &app.surface_config);
209 - app.text.resize(&app.queue, width, height);
210 - let cols = (width as f32 / CELL_ADVANCE).max(1.0) as u16;
211 - let rows = (height as f32 / CELL_HEIGHT).max(1.0) as u16;
220 + app.text.resize(&app.queue, physical_w, physical_h);
221 + // Tell the compositor our buffer is N logical px per buffer px.
222 + app.chrome.xdg_window.wl_surface().set_buffer_scale(s as i32);
223 + let cols = grid_cols(logical_w);
224 + let rows = grid_rows(logical_h);
212 225 app.grid.resize(cols, rows);
213 226 let _ = app.pty.resize(PtySize {
214 227 cols,
215 228 rows,
216 - cell_width: CELL_ADVANCE.round() as u16,
217 - cell_height: CELL_HEIGHT.round() as u16,
229 + cell_width: (CELL_ADVANCE * s as f32).round() as u16,
230 + cell_height: (CELL_HEIGHT * s as f32).round() as u16,
218 231 });
219 232 app.pending_redraw = true;
220 233 }
@@ -233,16 +246,30 @@
233 246 Ok(())
234 247 }
235 248
249 + fn grid_cols(px_w: u32) -> u16 {
250 + let usable = (px_w as f32 - 2.0 * PAD_X).max(CELL_ADVANCE);
251 + (usable / CELL_ADVANCE) as u16
252 + }
253 +
254 + fn grid_rows(px_h: u32) -> u16 {
255 + let usable = (px_h as f32 - 2.0 * PAD_Y).max(CELL_HEIGHT);
256 + (usable / CELL_HEIGHT) as u16
257 + }
258 +
236 259 struct App {
237 260 chrome: WaylandChrome,
238 261 surface: wgpu::Surface<'static>,
239 262 surface_config: wgpu::SurfaceConfiguration,
263 + surface_format: wgpu::TextureFormat,
240 264 device: Arc<wgpu::Device>,
241 265 queue: Arc<wgpu::Queue>,
242 266 text: TextRenderer,
243 267 pty: Pty,
244 268 grid: Grid,
245 269 parser: vte::Parser,
270 + font_data: Vec<u8>,
271 + /// HiDPI integer scale from `wl_surface.enter` outputs.
272 + scale: u32,
246 273 pending_redraw: bool,
247 274 }
248 275
@@ -274,31 +301,38 @@
274 301 ..Default::default()
275 302 });
276 303 }
277 - // Render each row of the grid as a single line. Colors and per-cell
278 - // attributes come next milestone. The parser has already resolved wrap,
279 - // control bytes, and cursor motion, so this is just character emission.
304 + // Build a batch of cell-anchored glyph draws. Positions are in PHYSICAL
305 + // pixels (matches surface + text uniform), so scale-multiply here.
306 + // Ignoring per-glyph font advance is deliberate: monospace terminal cells
307 + // must line up regardless of what the font wants.
308 + let s = app.scale as f32;
309 + let mut batch: Vec<CellDraw> = Vec::with_capacity(
310 + app.grid.rows() as usize * app.grid.cols() as usize,
311 + );
280 312 for r in 0..app.grid.rows() {
281 - let line: String = app
282 - .grid
283 - .row(r)
284 - .iter()
285 - .map(|c| if c.c == '\0' { ' ' } else { c.c })
286 - .collect();
287 - let trimmed = line.trim_end_matches(' ');
288 - if !trimmed.is_empty() {
289 - let y = r as f32 * CELL_HEIGHT;
290 - app.text.draw(
291 - &app.device,
292 - &app.queue,
293 - &view,
294 - &mut encoder,
295 - trimmed,
296 - 4.0,
313 + let y = (PAD_Y + r as f32 * CELL_HEIGHT) * s;
314 + for (col, cell) in app.grid.row(r).iter().enumerate() {
315 + let c = cell.c;
316 + if c == ' ' || c == '\0' {
317 + continue;
318 + }
319 + batch.push(CellDraw {
320 + c,
321 + x: (PAD_X + col as f32 * CELL_ADVANCE) * s,
297 322 y,
298 - TEXT_COLOR,
299 - );
323 + color: TEXT_COLOR,
324 + });
300 325 }
301 326 }
327 + if !batch.is_empty() {
328 + app.text.draw_cells(
329 + &app.device,
330 + &app.queue,
331 + &view,
332 + &mut encoder,
333 + &batch,
334 + );
335 + }
302 336 app.queue.submit(std::iter::once(encoder.finish()));
303 337 app.queue.present(frame);
304 338 Ok(())
@@ -312,8 +346,32 @@
312 346 _: &Connection,
313 347 _: &QueueHandle<Self>,
314 348 _: &wl_surface::WlSurface,
315 - _: i32,
349 + new_factor: i32,
316 350 ) {
351 + let new_scale = (new_factor.max(1) as u32).min(8);
352 + if new_scale == self.scale {
353 + return;
354 + }
355 + self.scale = new_scale;
356 + match TextRenderer::new(
357 + &self.device,
358 + self.surface_format,
359 + self.font_data.clone(),
360 + FONT_PX * new_scale as f32,
361 + ) {
362 + Ok(t) => self.text = t,
363 + Err(e) => {
364 + tracing::warn!("rebuild text at scale {new_scale}: {e:?}");
365 + return;
366 + }
367 + }
368 + // Re-queue a resize with the current logical dims so the surface
369 + // reconfigures at the new physical resolution and set_buffer_scale
370 + // fires.
371 + self.chrome.pending.push_back(Pending::Resized {
372 + width: self.chrome.width,
373 + height: self.chrome.height,
374 + });
317 375 }
318 376 fn transform_changed(
319 377 &mut self,