Skip to main content

max / shop

Colors, cursor as activity light, modifier-carrying keys shop-grid: - Color::{Default, Named, Indexed, Rgb} and an Attrs struct. - Cell carries fg/bg/attrs; Grid.pending_{fg,bg,attrs} track the current SGR state and stamp each printed cell. - Full SGR parser: reset, bold/dim/italic/underline/reverse/strikethrough, 30-37/90-97 fg named, 40-47/100-107 bg named, 38/48 with both `;5;N` and `;2;R;G;B` forms, 22/23/24/27/29 attribute clears. - CursorShape::{Block, Underline, Bar} settable via DECSCUSR (CSI Ps SP q). shop-render: - GlyphAtlas reserves a 4x4 white block at origin and exposes solid_uv; solid-color quads sample it to reuse the glyph pipeline. - New BgFill type + TextRenderer::draw_frame(fills, cells) batches bg fills and glyph cells into one instanced draw call. shop binary: - Palette: 16 named base + xterm 256 cube + grayscale + truecolor. - Cell fg/bg resolved per cell; reverse-attr swaps them. - Cursor renders as a BgFill at cursor cell, shape from DECSCUSR (block full cell / underline 2px bottom / bar 2px left). - Cursor doubles as a shell-activity light: cursor_phase toggles on every PTY-read chunk that returns bytes. Rapid output -> visible strobe (bright <-> dim alpha); idle prompt -> steady bright. No timer needed: Wayland is event-driven and we redraw on each read. - Arrow / Home / End / PageUp/Down / Insert / Delete now emit xterm modifier-carrying sequences (`\e[1;{m}{L}` / `\e[C;{m}~`) so helix, vim, nvim can bind shift/ctrl/alt+arrow. Follow-ups: bold/italic not visually distinct (single-weight font), OSC title/OSC 52 not handled, ligatures still off (deliberate for cell align), font swap to Iosevka Nerd Font pending.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 20:19 UTC
Signed with PGP, not checked
Commit: cdc3c1de92f6ab3df7c81626d0f788a35466237f
Parent: 341f33e
5 files changed, +461 insertions, -47 deletions
@@ -10,18 +10,60 @@
10 10 use tracing::trace;
11 11 use vte::{Params, Perform};
12 12
13 + /// A terminal color.
14 + ///
15 + /// [`Color::Default`] means "resolve at render time to the theme's default fg
16 + /// or bg" — kept out of `Rgb` so we don't lose the "this cell was never
17 + /// styled" signal (matters for e.g. transparent backgrounds).
18 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
19 + pub enum Color {
20 + Default,
21 + Named(u8),
22 + Indexed(u8),
23 + Rgb(u8, u8, u8),
24 + }
25 +
26 + /// SGR-set attributes for a cell. Not rendered visually yet; parsed for
27 + /// completeness and to keep the field shape stable.
28 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
29 + pub struct Attrs {
30 + pub bold: bool,
31 + pub italic: bool,
32 + pub underline: bool,
33 + pub reverse: bool,
34 + pub dim: bool,
35 + pub strikethrough: bool,
36 + }
37 +
13 38 /// One cell of the display grid.
14 39 #[derive(Copy, Clone, Debug, PartialEq)]
15 40 pub struct Cell {
16 41 pub c: char,
42 + pub fg: Color,
43 + pub bg: Color,
44 + pub attrs: Attrs,
17 45 }
18 46
19 47 impl Default for Cell {
20 48 fn default() -> Self {
21 - Self { c: ' ' }
49 + Self {
50 + c: ' ',
51 + fg: Color::Default,
52 + bg: Color::Default,
53 + attrs: Attrs::default(),
54 + }
22 55 }
23 56 }
24 57
58 + /// Shape hint from DECSCUSR (`CSI Ps SP q`). Blink flag is ignored — MVP
59 + /// renders all as steady.
60 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
61 + pub enum CursorShape {
62 + Block,
63 + Underline,
64 + Bar,
65 + }
66 +
25 67 /// Cursor state (position + deferred-wrap flag).
26 68 #[derive(Copy, Clone, Debug, Default)]
27 69 pub struct Cursor {
@@ -42,10 +84,14 @@
42 84 alt: Vec<Cell>,
43 85 on_alt: bool,
44 86 cursor: Cursor,
87 + cursor_shape: CursorShape,
45 88 saved_main_cursor: Cursor,
46 89 saved_alt_cursor: Cursor,
47 90 scroll_top: u16, // 0-indexed, inclusive
48 91 scroll_bottom: u16, // 0-indexed, inclusive
92 + pending_fg: Color,
93 + pending_bg: Color,
94 + pending_attrs: Attrs,
49 95 }
50 96
51 97 impl Grid {
@@ -63,13 +109,21 @@
63 109 visible: true,
64 110 ..Cursor::default()
65 111 },
112 + cursor_shape: CursorShape::Block,
66 113 saved_main_cursor: Cursor::default(),
67 114 saved_alt_cursor: Cursor::default(),
68 115 scroll_top: 0,
69 116 scroll_bottom: rows - 1,
117 + pending_fg: Color::Default,
118 + pending_bg: Color::Default,
119 + pending_attrs: Attrs::default(),
70 120 }
71 121 }
72 122
123 + pub fn cursor_shape(&self) -> CursorShape {
124 + self.cursor_shape
125 + }
126 +
73 127 pub fn cols(&self) -> u16 {
74 128 self.cols
75 129 }
@@ -132,7 +186,12 @@
132 186 self.cursor.wrap_next = false;
133 187 }
134 188 let idx = self.cell_index(self.cursor.row, self.cursor.col);
135 - self.active_cells_mut()[idx] = Cell { c };
189 + self.active_cells_mut()[idx] = Cell {
190 + c,
191 + fg: self.pending_fg,
192 + bg: self.pending_bg,
193 + attrs: self.pending_attrs,
194 + };
136 195 if self.cursor.col + 1 >= self.cols {
137 196 self.cursor.wrap_next = true;
138 197 } else {
@@ -140,6 +199,57 @@
140 199 }
141 200 }
142 201
202 + fn apply_sgr(&mut self, params: &Params) {
203 + // Flatten all subparameter groups into one stream so both the
204 + // xterm-style `38;2;R;G;B` and the ITU-T `38:2::R:G:B` forms work.
205 + let flat: Vec<u16> = params.iter().flat_map(|p| p.iter().copied()).collect();
206 + let mut i = 0;
207 + while i < flat.len() {
208 + let p = flat[i];
209 + match p {
210 + 0 => {
211 + self.pending_fg = Color::Default;
212 + self.pending_bg = Color::Default;
213 + self.pending_attrs = Attrs::default();
214 + }
215 + 1 => self.pending_attrs.bold = true,
216 + 2 => self.pending_attrs.dim = true,
217 + 3 => self.pending_attrs.italic = true,
218 + 4 => self.pending_attrs.underline = true,
219 + 7 => self.pending_attrs.reverse = true,
220 + 9 => self.pending_attrs.strikethrough = true,
221 + 22 => {
222 + self.pending_attrs.bold = false;
223 + self.pending_attrs.dim = false;
224 + }
225 + 23 => self.pending_attrs.italic = false,
226 + 24 => self.pending_attrs.underline = false,
227 + 27 => self.pending_attrs.reverse = false,
228 + 29 => self.pending_attrs.strikethrough = false,
229 + 30..=37 => self.pending_fg = Color::Named((p - 30) as u8),
230 + 38 => {
231 + if let Some((c, consumed)) = parse_extended_color(&flat[i + 1..]) {
232 + self.pending_fg = c;
233 + i += consumed;
234 + }
235 + }
236 + 39 => self.pending_fg = Color::Default,
237 + 40..=47 => self.pending_bg = Color::Named((p - 40) as u8),
238 + 48 => {
239 + if let Some((c, consumed)) = parse_extended_color(&flat[i + 1..]) {
240 + self.pending_bg = c;
241 + i += consumed;
242 + }
243 + }
244 + 49 => self.pending_bg = Color::Default,
245 + 90..=97 => self.pending_fg = Color::Named(((p - 90) + 8) as u8),
246 + 100..=107 => self.pending_bg = Color::Named(((p - 100) + 8) as u8),
247 + _ => {}
248 + }
249 + i += 1;
250 + }
251 + }
252 +
143 253 fn newline(&mut self) {
144 254 if self.cursor.row < self.scroll_bottom {
145 255 self.cursor.row += 1;
@@ -253,6 +363,22 @@
253 363 }
254 364 }
255 365
366 + /// Parse the tail of a `38;…` or `48;…` extended-color SGR. Returns the
367 + /// parsed color and the number of parameters consumed *after* the leading
368 + /// `38`/`48`. Both `;5;N` (indexed) and `;2;R;G;B` (truecolor) supported.
369 + fn parse_extended_color(rest: &[u16]) -> Option<(Color, usize)> {
370 + match rest.first().copied()? {
371 + 5 => rest.get(1).map(|&i| (Color::Indexed(i.min(255) as u8), 2)),
372 + 2 => {
373 + let r = rest.get(1).copied().unwrap_or(0).min(255) as u8;
374 + let g = rest.get(2).copied().unwrap_or(0).min(255) as u8;
375 + let b = rest.get(3).copied().unwrap_or(0).min(255) as u8;
376 + Some((Color::Rgb(r, g, b), 4))
377 + }
378 + _ => None,
379 + }
380 + }
381 +
256 382 fn resize_buf(
257 383 old: &[Cell],
258 384 old_cols: u16,
@@ -390,8 +516,15 @@
390 516 self.cursor.row = 0;
391 517 self.cursor.col = 0;
392 518 }
393 - ('m', false) => {
394 - // SGR — accept and ignore for now (colors come next milestone).
519 + ('m', false) => self.apply_sgr(params),
520 + ('q', false) if intermediates.first().copied() == Some(b' ') => {
521 + let shape = param1(params, 1);
522 + self.cursor_shape = match shape {
523 + 0 | 1 | 2 => CursorShape::Block,
524 + 3 | 4 => CursorShape::Underline,
525 + 5 | 6 => CursorShape::Bar,
526 + _ => self.cursor_shape,
527 + };
395 528 }
396 529 ('h', true) | ('l', true) => {
397 530 for p in params.iter() {
@@ -6,6 +6,9 @@
6 6 pub(crate) texture: wgpu::Texture,
7 7 pub(crate) view: wgpu::TextureView,
8 8 pub(crate) size: u32,
9 + /// UV coordinate of a pre-uploaded white pixel; solid-color quads sample
10 + /// this to bypass the glyph atlas without needing a second pipeline.
11 + pub(crate) solid_uv: [f32; 2],
9 12 allocator: AtlasAllocator,
10 13 }
11 14
@@ -18,7 +21,7 @@
18 21 }
19 22
20 23 impl GlyphAtlas {
21 - pub(crate) fn new(device: &wgpu::Device, size: u32) -> Self {
24 + pub(crate) fn new(device: &wgpu::Device, queue: &wgpu::Queue, size: u32) -> Self {
22 25 let texture = device.create_texture(&wgpu::TextureDescriptor {
23 26 label: Some("shop.glyph_atlas"),
24 27 size: wgpu::Extent3d {
@@ -34,11 +37,42 @@
34 37 view_formats: &[],
35 38 });
36 39 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
37 - let allocator = AtlasAllocator::new(size2(size as i32, size as i32));
40 + let mut allocator = AtlasAllocator::new(size2(size as i32, size as i32));
41 +
42 + // Reserve a 4x4 white block at the top-left and record its center UV.
43 + // Solid-color quads sample this to reuse the glyph pipeline.
44 + let solid_alloc = allocator
45 + .allocate(size2(4, 4))
46 + .expect("atlas must fit a 4x4 solid reservation");
47 + let sx = solid_alloc.rectangle.min.x as u32;
48 + let sy = solid_alloc.rectangle.min.y as u32;
49 + queue.write_texture(
50 + wgpu::TexelCopyTextureInfo {
51 + texture: &texture,
52 + mip_level: 0,
53 + origin: wgpu::Origin3d { x: sx, y: sy, z: 0 },
54 + aspect: wgpu::TextureAspect::All,
55 + },
56 + &[255u8; 16],
57 + wgpu::TexelCopyBufferLayout {
58 + offset: 0,
59 + bytes_per_row: Some(4),
60 + rows_per_image: Some(4),
61 + },
62 + wgpu::Extent3d {
63 + width: 4,
64 + height: 4,
65 + depth_or_array_layers: 1,
66 + },
67 + );
68 + let s = size as f32;
69 + let solid_uv = [(sx as f32 + 2.0) / s, (sy as f32 + 2.0) / s];
70 +
38 71 Self {
39 72 texture,
40 73 view,
41 74 size,
75 + solid_uv,
42 76 allocator,
43 77 }
44 78 }
@@ -12,4 +12,4 @@
12 12 mod pipeline;
13 13 mod shaper;
14 14
15 - pub use pipeline::{CellDraw, TextRenderer};
15 + pub use pipeline::{BgFill, CellDraw, TextRenderer};
@@ -21,6 +21,16 @@
21 21 pub color: [f32; 4],
22 22 }
23 23
24 + /// Solid-color rectangle. Used for cell backgrounds and the cursor.
25 + #[derive(Debug, Clone, Copy)]
26 + pub struct BgFill {
27 + pub x: f32,
28 + pub y: f32,
29 + pub w: f32,
30 + pub h: f32,
31 + pub color: [f32; 4],
32 + }
33 +
24 34 const ATLAS_SIZE: u32 = 1024;
25 35
26 36 #[repr(C)]
@@ -110,11 +120,12 @@
110 120 impl TextRenderer {
111 121 pub fn new(
112 122 device: &wgpu::Device,
123 + queue: &wgpu::Queue,
113 124 format: wgpu::TextureFormat,
114 125 font_data: Vec<u8>,
115 126 font_px: f32,
116 127 ) -> anyhow::Result<Self> {
117 - let atlas = GlyphAtlas::new(device, ATLAS_SIZE);
128 + let atlas = GlyphAtlas::new(device, queue, ATLAS_SIZE);
118 129 let shaper = Shaper::new(font_data, font_px)?;
119 130
120 131 let uniforms_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -267,6 +278,103 @@
267 278 })
268 279 }
269 280
281 + /// Draw a frame: solid-color fills first (bg + cursor), glyph cells second.
282 + /// All emitted as a single instanced draw call using the atlas's reserved
283 + /// solid-white block for the fill quads.
284 + pub fn draw_frame(
285 + &mut self,
286 + device: &wgpu::Device,
287 + queue: &wgpu::Queue,
288 + view: &wgpu::TextureView,
289 + encoder: &mut wgpu::CommandEncoder,
290 + fills: &[BgFill],
291 + cells: &[CellDraw],
292 + ) {
293 + let ascent = self.shaper.ascent();
294 + let solid_uv = self.atlas.solid_uv;
295 + let mut instances: Vec<Instance> = Vec::with_capacity(fills.len() + cells.len());
296 +
297 + // Fills go first so alpha blending stacks glyphs on top.
298 + for fill in fills {
299 + instances.push(Instance {
300 + pos: [fill.x, fill.y],
301 + size: [fill.w, fill.h],
302 + uv_min: solid_uv,
303 + uv_max: solid_uv,
304 + color: fill.color,
305 + });
306 + }
307 +
308 + for cell in cells {
309 + let glyph_id = self.shaper.glyph_id_for(cell.c);
310 + let cached = match self.cache.get(&glyph_id).copied() {
311 + Some(v) => v,
312 + None => {
313 + let entry = self.shaper.rasterize(glyph_id).and_then(|r| {
314 + self.atlas
315 + .upload(queue, r.width, r.height, &r.bitmap)
316 + .map(|slot| CachedGlyph {
317 + slot,
318 + left: r.placement_left,
319 + top: r.placement_top,
320 + })
321 + });
322 + self.cache.insert(glyph_id, entry);
323 + entry
324 + }
325 + };
326 + if let Some(c) = cached
327 + && c.slot.px[0] > 0
328 + && c.slot.px[1] > 0
329 + {
330 + instances.push(Instance {
331 + pos: [cell.x + c.left as f32, cell.y + ascent - c.top as f32],
332 + size: [c.slot.px[0] as f32, c.slot.px[1] as f32],
333 + uv_min: c.slot.uv_min,
334 + uv_max: c.slot.uv_max,
335 + color: cell.color,
336 + });
337 + }
338 + }
339 +
340 + if instances.is_empty() {
341 + return;
342 + }
343 +
344 + let needed = instances.len() as u64;
345 + if needed > self.instance_cap {
346 + let new_cap = needed.next_power_of_two();
347 + self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
348 + label: Some("shop.text.instances"),
349 + size: new_cap * std::mem::size_of::<Instance>() as u64,
350 + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
351 + mapped_at_creation: false,
352 + });
353 + self.instance_cap = new_cap;
354 + }
355 + queue.write_buffer(&self.instance_buf, 0, bytemuck::cast_slice(&instances));
356 +
357 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
358 + label: Some("shop.text.frame"),
359 + color_attachments: &[Some(wgpu::RenderPassColorAttachment {
360 + view,
361 + resolve_target: None,
362 + ops: wgpu::Operations {
363 + load: wgpu::LoadOp::Load,
364 + store: wgpu::StoreOp::Store,
365 + },
366 + depth_slice: None,
367 + })],
368 + ..Default::default()
369 + });
370 + pass.set_pipeline(&self.pipeline);
371 + pass.set_bind_group(0, &self.bind_group, &[]);
372 + pass.set_vertex_buffer(0, self.quad_buf.slice(..));
373 + pass.set_vertex_buffer(1, self.instance_buf.slice(..));
374 + pass.set_index_buffer(self.index_buf.slice(..), wgpu::IndexFormat::Uint16);
375 + pass.draw_indexed(0..6, 0, 0..instances.len() as u32);
376 + }
377 +
270 378 /// Draw a batch of cell-anchored characters in one instanced draw call.
271 379 /// Uses the font's charmap for glyph lookup (no shaping), so ligatures do
272 380 /// not activate and per-glyph advance is ignored — each character sits at
@@ -12,9 +12,9 @@
12 12 use calloop::EventLoop;
13 13 use calloop::generic::{FdWrapper, Generic};
14 14 use calloop_wayland_source::WaylandSource;
15 - use shop_grid::Grid;
15 + use shop_grid::{Color as GridColor, CursorShape, Grid};
16 16 use shop_pty::{Pty, PtySize};
17 - use shop_render::{CellDraw, TextRenderer};
17 + use shop_render::{BgFill, CellDraw, TextRenderer};
18 18 use shop_wayland::{
19 19 Capability, CompositorHandler, CompositorState, Connection, OutputHandler, OutputState,
20 20 Pending, ProvidesRegistryState, QueueHandle, RegistryState, SeatHandler, SeatState,
@@ -44,7 +44,13 @@
44 44 b: 0.086,
45 45 a: 1.0,
46 46 };
47 - const TEXT_COLOR: [f32; 4] = [0.90, 0.92, 0.96, 1.0];
47 + const DEFAULT_FG: [f32; 4] = [0.90, 0.92, 0.96, 1.0];
48 + const DEFAULT_BG: [f32; 4] = [0.043, 0.055, 0.086, 1.0];
49 + /// Warm-white cursor. Doubles as the disk-activity light: its "phase"
50 + /// (visible / dim) toggles on every PTY-read chunk, so rapid shell output
51 + /// produces visible flicker while an idle prompt stays steady.
52 + const CURSOR_COLOR_ON: [f32; 4] = [1.0, 0.85, 0.55, 0.85];
53 + const CURSOR_COLOR_DIM: [f32; 4] = [1.0, 0.85, 0.55, 0.28];
48 54
49 55 fn main() -> anyhow::Result<()> {
50 56 tracing_subscriber::fmt()
@@ -142,7 +148,7 @@
142 148 desired_maximum_frame_latency: 2,
143 149 present_mode: wgpu::PresentMode::Mailbox,
144 150 };
145 - let text = TextRenderer::new(&device, format, font_data, FONT_PX)?;
151 + let text = TextRenderer::new(&device, &queue, format, font_data, FONT_PX)?;
146 152
147 153 let grid = Grid::new(cols_initial, rows_initial);
148 154 let parser = vte::Parser::new();
@@ -161,6 +167,7 @@
161 167 scale: 1,
162 168 keyboard: None,
163 169 modifiers: Modifiers::default(),
170 + cursor_phase: true,
164 171 pending_redraw: true,
165 172 };
166 173 app.surface.configure(&app.device, &app.surface_config);
@@ -195,6 +202,7 @@
195 202 }
196 203 Ok(n) => {
197 204 app.parser.advance(&mut app.grid, &buf[..n]);
205 + app.cursor_phase = !app.cursor_phase;
198 206 app.pending_redraw = true;
199 207 }
200 208 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
@@ -279,6 +287,9 @@
279 287 scale: u32,
280 288 keyboard: Option<wl_keyboard::WlKeyboard>,
281 289 modifiers: Modifiers,
290 + /// Cursor "activity light" phase. Toggles on every PTY-read chunk. Idle
291 + /// prompt = steady on; heavy output = visible strobe.
292 + cursor_phase: bool,
282 293 pending_redraw: bool,
283 294 }
284 295
@@ -310,37 +321,68 @@
310 321 ..Default::default()
311 322 });
312 323 }
313 - // Build a batch of cell-anchored glyph draws. Positions are in PHYSICAL
314 - // pixels (matches surface + text uniform), so scale-multiply here.
315 - // Ignoring per-glyph font advance is deliberate: monospace terminal cells
316 - // must line up regardless of what the font wants.
324 + // Build one batch per frame: bg fills (per-cell backgrounds + cursor)
325 + // then glyph cells. All positions in PHYSICAL px (surface uniform).
317 326 let s = app.scale as f32;
318 - let mut batch: Vec<CellDraw> = Vec::with_capacity(
327 + let cell_w_px = CELL_ADVANCE * s;
328 + let cell_h_px = CELL_HEIGHT * s;
329 + let mut fills: Vec<BgFill> = Vec::new();
330 + let mut cells: Vec<CellDraw> = Vec::with_capacity(
319 331 app.grid.rows() as usize * app.grid.cols() as usize,
320 332 );
333 + let cursor = app.grid.cursor();
334 + let cursor_shape = app.grid.cursor_shape();
335 +
321 336 for r in 0..app.grid.rows() {
322 337 let y = (PAD_Y + r as f32 * CELL_HEIGHT) * s;
323 338 for (col, cell) in app.grid.row(r).iter().enumerate() {
324 - let c = cell.c;
325 - if c == ' ' || c == '\0' {
326 - continue;
339 + let x = (PAD_X + col as f32 * CELL_ADVANCE) * s;
340 + let mut fg = resolve_color(cell.fg, DEFAULT_FG);
341 + let mut bg = resolve_color(cell.bg, DEFAULT_BG);
342 + if cell.attrs.reverse {
343 + std::mem::swap(&mut fg, &mut bg);
344 + }
345 + if cell.bg != GridColor::Default || cell.attrs.reverse {
346 + fills.push(BgFill {
347 + x,
348 + y,
349 + w: cell_w_px,
350 + h: cell_h_px,
351 + color: bg,
352 + });
353 + }
354 + let c = cell.c;
355 + if c != ' ' && c != '\0' {
356 + cells.push(CellDraw { c, x, y, color: fg });
327 357 }
328 - batch.push(CellDraw {
329 - c,
330 - x: (PAD_X + col as f32 * CELL_ADVANCE) * s,
331 - y,
332 - color: TEXT_COLOR,
333 - });
334 358 }
335 359 }
336 - if !batch.is_empty() {
337 - app.text.draw_cells(
338 - &app.device,
339 - &app.queue,
340 - &view,
341 - &mut encoder,
342 - &batch,
343 - );
360 +
361 + if cursor.visible {
362 + let cx = (PAD_X + cursor.col as f32 * CELL_ADVANCE) * s;
363 + let cy = (PAD_Y + cursor.row as f32 * CELL_HEIGHT) * s;
364 + let color = if app.cursor_phase {
365 + CURSOR_COLOR_ON
366 + } else {
367 + CURSOR_COLOR_DIM
368 + };
369 + let (w, h, ox, oy) = match cursor_shape {
370 + CursorShape::Block => (cell_w_px, cell_h_px, 0.0, 0.0),
371 + CursorShape::Underline => (cell_w_px, 2.0 * s, 0.0, cell_h_px - 2.0 * s),
372 + CursorShape::Bar => (2.0 * s, cell_h_px, 0.0, 0.0),
373 + };
374 + fills.push(BgFill {
375 + x: cx + ox,
376 + y: cy + oy,
377 + w,
378 + h,
379 + color,
380 + });
381 + }
382 +
383 + if !fills.is_empty() || !cells.is_empty() {
384 + app.text
385 + .draw_frame(&app.device, &app.queue, &view, &mut encoder, &fills, &cells);
344 386 }
345 387 app.queue.submit(std::iter::once(encoder.finish()));
346 388 app.queue.present(frame);
@@ -364,6 +406,7 @@
364 406 self.scale = new_scale;
365 407 match TextRenderer::new(
366 408 &self.device,
409 + &self.queue,
367 410 self.surface_format,
368 411 self.font_data.clone(),
369 412 FONT_PX * new_scale as f32,
@@ -527,7 +570,7 @@
527 570
528 571 impl App {
529 572 fn handle_key(&mut self, event: &KeyEvent) {
530 - let bytes = key_to_bytes(event);
573 + let bytes = key_to_bytes(event, self.modifiers);
531 574 if !bytes.is_empty()
532 575 && let Err(e) = self.pty.write(&bytes)
533 576 {
@@ -539,23 +582,27 @@
539 582 /// Translate a Wayland key event into the byte sequence the shell expects.
540 583 /// SCTK/xkbcommon already runs the keymap so `event.utf8` carries the printable
541 584 /// bytes (including Ctrl-modified chars like Ctrl+C -> "\x03"). Special keys
542 - /// with no printable form get an xterm-style escape sequence.
543 - fn key_to_bytes(event: &KeyEvent) -> Vec<u8> {
585 + /// with no printable form get an xterm-style escape sequence, with modifier
586 + /// numbers appended for editors that consume them (helix, vim, nvim).
587 + fn key_to_bytes(event: &KeyEvent, mods: Modifiers) -> Vec<u8> {
588 + let m = xterm_mod(mods);
589 + let arrow = |letter: u8| xterm_arrow(letter, m);
590 + let tilde = |code: u8| xterm_tilde(code, m);
544 591 match event.keysym {
545 592 Keysym::Return | Keysym::KP_Enter => return b"\r".to_vec(),
546 593 Keysym::BackSpace => return b"\x7f".to_vec(),
547 594 Keysym::Tab => return b"\t".to_vec(),
548 595 Keysym::Escape => return b"\x1b".to_vec(),
549 - Keysym::Up => return b"\x1b[A".to_vec(),
550 - Keysym::Down => return b"\x1b[B".to_vec(),
551 - Keysym::Right => return b"\x1b[C".to_vec(),
552 - Keysym::Left => return b"\x1b[D".to_vec(),
553 - Keysym::Home => return b"\x1b[H".to_vec(),
554 - Keysym::End => return b"\x1b[F".to_vec(),
555 - Keysym::Page_Up => return b"\x1b[5~".to_vec(),
556 - Keysym::Page_Down => return b"\x1b[6~".to_vec(),
557 - Keysym::Insert => return b"\x1b[2~".to_vec(),
558 - Keysym::Delete => return b"\x1b[3~".to_vec(),
596 + Keysym::Up => return arrow(b'A'),
597 + Keysym::Down => return arrow(b'B'),
598 + Keysym::Right => return arrow(b'C'),
599 + Keysym::Left => return arrow(b'D'),
600 + Keysym::Home => return arrow(b'H'),
601 + Keysym::End => return arrow(b'F'),
602 + Keysym::Page_Up => return tilde(5),
603 + Keysym::Page_Down => return tilde(6),
604 + Keysym::Insert => return tilde(2),
605 + Keysym::Delete => return tilde(3),
559 606 _ => {}
560 607 }
561 608 event
@@ -565,6 +612,98 @@
565 612 .unwrap_or_default()
566 613 }
567 614
615 + /// xterm modifier number = 1 + Shift + 2*Alt + 4*Ctrl + 8*Meta.
616 + fn xterm_mod(m: Modifiers) -> u8 {
617 + let mut n: u8 = 0;
618 + if m.shift {
619 + n += 1;
620 + }
621 + if m.alt {
622 + n += 2;
623 + }
624 + if m.ctrl {
625 + n += 4;
626 + }
627 + if m.logo {
628 + n += 8;
629 + }
630 + n + 1
631 + }
632 +
633 + fn xterm_arrow(letter: u8, m: u8) -> Vec<u8> {
634 + if m == 1 {
635 + vec![0x1b, b'[', letter]
636 + } else {
637 + format!("\x1b[1;{m}{}", letter as char).into_bytes()
638 + }
639 + }
640 +
641 + fn xterm_tilde(code: u8, m: u8) -> Vec<u8> {
642 + if m == 1 {
643 + format!("\x1b[{code}~").into_bytes()
644 + } else {
645 + format!("\x1b[{code};{m}~").into_bytes()
646 + }
647 + }
648 +
649 + fn resolve_color(c: GridColor, default: [f32; 4]) -> [f32; 4] {
650 + match c {
651 + GridColor::Default => default,
652 + GridColor::Named(i) => palette16(i),
653 + GridColor::Indexed(i) => palette256(i),
654 + GridColor::Rgb(r, g, b) => [
655 + r as f32 / 255.0,
656 + g as f32 / 255.0,
657 + b as f32 / 255.0,
658 + 1.0,
659 + ],
660 + }
661 + }
662 +
663 + /// 16-color base palette. Values from a mid-neutral scheme close to
664 + /// alacritty's `iterm` — not the final theme, just enough that colored output
665 + /// is legible before we wire the Alloy TOKENS palette.
666 + const NAMED_16: [[u8; 3]; 16] = [
667 + [0x28, 0x2c, 0x34], // 0 black
668 + [0xe0, 0x6c, 0x75], // 1 red
669 + [0x98, 0xc3, 0x79], // 2 green
670 + [0xe5, 0xc0, 0x7b], // 3 yellow
671 + [0x61, 0xaf, 0xef], // 4 blue
672 + [0xc6, 0x78, 0xdd], // 5 magenta
673 + [0x56, 0xb6, 0xc2], // 6 cyan
674 + [0xab, 0xb2, 0xbf], // 7 white
675 + [0x5c, 0x63, 0x70], // 8 bright black (gray)
676 + [0xef, 0x8f, 0x96], // 9 bright red
677 + [0xb8, 0xd6, 0x93], // 10 bright green
678 + [0xf1, 0xd4, 0x99], // 11 bright yellow
679 + [0x8c, 0xc4, 0xf3], // 12 bright blue
680 + [0xd6, 0x99, 0xeb], // 13 bright magenta
681 + [0x83, 0xc7, 0xd0], // 14 bright cyan
682 + [0xc8, 0xcc, 0xd4], // 15 bright white
683 + ];
684 +
685 + fn palette16(i: u8) -> [f32; 4] {
686 + let [r, g, b] = NAMED_16[i.min(15) as usize];
687 + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
688 + }
689 +
690 + fn palette256(i: u8) -> [f32; 4] {
691 + if i < 16 {
692 + palette16(i)
693 + } else if i < 232 {
694 + let v = i - 16;
695 + let cube = [0u8, 95, 135, 175, 215, 255];
696 + let r = cube[(v / 36) as usize];
697 + let g = cube[((v / 6) % 6) as usize];
698 + let b = cube[(v % 6) as usize];
699 + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
700 + } else {
701 + let g = 8 + (i - 232) as u16 * 10;
702 + let g = g.min(255) as f32 / 255.0;
703 + [g, g, g, 1.0]
704 + }
705 + }
706 +
568 707 impl WindowHandler for App {
569 708 fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &XdgWindow) {
570 709 self.chrome.exit = true;