Skip to main content

max / shop

Minor batch: OSC title, underline attr, Alt-prefix, focus dim shop-grid: - Grid gains pending_title + take_pending_title() drain. - osc_dispatch handles OSC 0 and OSC 2 (icon+title / title-only); OSC 1 is a no-op on Wayland (no separate icon-name concept). shop binary: - PTY-read callback polls take_pending_title after parser.advance and forwards to xdg_window.set_title. - Render emits a 1.5x-scaled fill under any cell with attrs.underline (helix search matches, diagnostics, etc.). - Alt+letter now prepends ESC to the utf8 payload (xterm "Alt sends escape" convention). Editor bindings like helix's Alt+d, Alt+w work. - KeyboardHandler::enter/leave track surface focus; cursor fill's alpha is multiplied by 0.35 when unfocused so it's obvious which window is receiving input.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 20:28 UTC
Signed with PGP, not checked
Commit: 37dabef242c2db91f7b891e0db112eace679b16e
Parent: 41f4608
2 files changed, +67 insertions, -8 deletions
@@ -92,6 +92,7 @@
92 92 pending_fg: Color,
93 93 pending_bg: Color,
94 94 pending_attrs: Attrs,
95 + pending_title: Option<String>,
95 96 }
96 97
97 98 impl Grid {
@@ -117,6 +118,7 @@
117 118 pending_fg: Color::Default,
118 119 pending_bg: Color::Default,
119 120 pending_attrs: Attrs::default(),
121 + pending_title: None,
120 122 }
121 123 }
122 124
@@ -124,6 +126,13 @@
124 126 self.cursor_shape
125 127 }
126 128
129 + /// Consume any window title set by the shell via OSC 0/2 since the last
130 + /// call. Binary polls after each `parser.advance` and forwards to
131 + /// `xdg_window.set_title`.
132 + pub fn take_pending_title(&mut self) -> Option<String> {
133 + self.pending_title.take()
134 + }
135 +
127 136 pub fn cols(&self) -> u16 {
128 137 self.cols
129 138 }
@@ -557,7 +566,21 @@
557 566 }
558 567 }
559 568
560 - fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
561 - // Title (OSC 0/2), hyperlinks (OSC 8), clipboard (OSC 52) — later.
569 + fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
570 + let Some(id) = params.first().and_then(|p| std::str::from_utf8(p).ok()) else {
571 + return;
572 + };
573 + match id {
574 + // OSC 0 = icon + title, OSC 2 = title only. OSC 1 = icon-only,
575 + // treat as no-op (Wayland has no separate icon-name concept).
576 + "0" | "2" => {
577 + if let Some(payload) = params.get(1)
578 + && let Ok(s) = std::str::from_utf8(payload)
579 + {
580 + self.pending_title = Some(s.to_string());
581 + }
582 + }
583 + _ => {}
584 + }
562 585 }
563 586 }
@@ -171,6 +171,7 @@
171 171 keyboard: None,
172 172 modifiers: Modifiers::default(),
173 173 cursor_phase: true,
174 + focused: false,
174 175 pending_redraw: true,
175 176 };
176 177 app.surface.configure(&app.device, &app.surface_config);
@@ -205,6 +206,9 @@
205 206 }
206 207 Ok(n) => {
207 208 app.parser.advance(&mut app.grid, &buf[..n]);
209 + if let Some(title) = app.grid.take_pending_title() {
210 + app.chrome.xdg_window.set_title(title);
211 + }
208 212 app.cursor_phase = !app.cursor_phase;
209 213 app.pending_redraw = true;
210 214 }
@@ -293,6 +297,9 @@
293 297 /// Cursor "activity light" phase. Toggles on every PTY-read chunk. Idle
294 298 /// prompt = steady on; heavy output = visible strobe.
295 299 cursor_phase: bool,
300 + /// True while our surface has keyboard focus. Cursor dims when unfocused
301 + /// so you can tell at a glance which window is receiving input.
302 + focused: bool,
296 303 pending_redraw: bool,
297 304 }
298 305
@@ -354,6 +361,15 @@
354 361 color: bg,
355 362 });
356 363 }
364 + if cell.attrs.underline {
365 + fills.push(BgFill {
366 + x,
367 + y: y + cell_h_px - 2.0 * s,
368 + w: cell_w_px,
369 + h: 1.5 * s,
370 + color: fg,
371 + });
372 + }
357 373 let c = cell.c;
358 374 if c != ' ' && c != '\0' {
359 375 cells.push(CellDraw { c, x, y, color: fg });
@@ -364,11 +380,17 @@
364 380 if cursor.visible {
365 381 let cx = (PAD_X + cursor.col as f32 * CELL_ADVANCE) * s;
366 382 let cy = (PAD_Y + cursor.row as f32 * CELL_HEIGHT) * s;
367 - let color = if app.cursor_phase {
383 + let base = if app.cursor_phase {
368 384 CURSOR_COLOR_ON
369 385 } else {
370 386 CURSOR_COLOR_DIM
371 387 };
388 + // Dim to a hollow-looking ghost when the window doesn't have focus.
389 + let color = if app.focused {
390 + base
391 + } else {
392 + [base[0], base[1], base[2], base[3] * 0.35]
393 + };
372 394 let (w, h, ox, oy) = match cursor_shape {
373 395 CursorShape::Block => (cell_w_px, cell_h_px, 0.0, 0.0),
374 396 CursorShape::Underline => (cell_w_px, 2.0 * s, 0.0, cell_h_px - 2.0 * s),
@@ -510,6 +532,8 @@
510 532 _: &[u32],
511 533 _: &[Keysym],
512 534 ) {
535 + self.focused = true;
536 + self.pending_redraw = true;
513 537 }
514 538 fn leave(
515 539 &mut self,
@@ -519,6 +543,8 @@
519 543 _: &wl_surface::WlSurface,
520 544 _: u32,
521 545 ) {
546 + self.focused = false;
547 + self.pending_redraw = true;
522 548 }
523 549 fn press_key(
524 550 &mut self,
@@ -608,11 +634,21 @@
608 634 Keysym::Delete => return tilde(3),
609 635 _ => {}
610 636 }
611 - event
612 - .utf8
613 - .as_ref()
614 - .map(|s| s.as_bytes().to_vec())
615 - .unwrap_or_default()
637 + let utf8 = event.utf8.as_deref().unwrap_or_default();
638 + if utf8.is_empty() {
639 + return Vec::new();
640 + }
641 + // xterm "Alt sends escape" convention: Alt+letter prepends ESC. Deferred
642 + // dead-key handling: users with Compose/deadkey layouts may want this off
643 + // via config once TOML lands.
644 + if mods.alt {
645 + let mut out = Vec::with_capacity(utf8.len() + 1);
646 + out.push(0x1b);
647 + out.extend_from_slice(utf8.as_bytes());
648 + out
649 + } else {
650 + utf8.as_bytes().to_vec()
651 + }
616 652 }
617 653
618 654 /// xterm modifier number = 1 + Shift + 2*Alt + 4*Ctrl + 8*Meta.