max / shop
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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; |