Skip to main content

max / shop

Split shop's main.rs into six modules main.rs was 2961 lines carrying argument parsing, pointer handling, drawing, window chrome, the kitty graphics protocol and key input alongside the event loop. Each becomes a module; main.rs keeps 728 lines and the loop. clipboard.rs takes the clipboard and primary-selection App methods, which belonged with it rather than in a new file. Six tests are new, in chrome, graphics and input. They exist for the same reason shop-grid's do: a carved module with no literal #[cfg(test)] leaves mutation scope without saying so. 435 tests before, 441 after, verified by diffing test names rather than counts so an addition could not mask a loss. Zero lost.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:38 UTC
Signed with PGP, not checked
Commit: 66faa011c4fc2bcee650882f61c4d017d994759d
Parent: 8bf9d8c
8 files changed, +2383 insertions, -421 deletions
@@ -7,10 +7,28 @@
7 7 //! this way since X, and collapsing the two so that selecting text clobbers
8 8 //! what you copied ten minutes ago is a genuine loss of a working register.
9 9 //!
10 - //! The Wayland plumbing for both lives in `main.rs`, because it needs the SCTK
11 - //! handler traits on the app state. What lives here is the part with rules
12 - //! worth testing: turning bytes from another program into bytes the shell
13 - //! should see.
10 + //! Both registers are here: the SCTK handlers that carry them, the `App`
11 + //! methods that take and give ownership, and `sanitize_paste`, which turns
12 + //! bytes from another program into bytes the shell should see.
13 +
14 + use crate::App;
15 + use shop_wayland::{Connection, QueueHandle};
16 + use smithay_client_toolkit::data_device_manager::data_device::DataDeviceHandler;
17 + use smithay_client_toolkit::data_device_manager::data_offer::{DataOfferHandler, DragOffer};
18 + use smithay_client_toolkit::data_device_manager::data_source::{
19 + CopyPasteSource, DataSourceHandler,
20 + };
21 + use smithay_client_toolkit::data_device_manager::{ReadPipe, WritePipe};
22 + use smithay_client_toolkit::primary_selection::device::PrimarySelectionDeviceHandler;
23 + use smithay_client_toolkit::primary_selection::selection::{
24 + PrimarySelectionSource, PrimarySelectionSourceHandler,
25 + };
26 + use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{
27 + zwp_primary_selection_device_v1, zwp_primary_selection_source_v1,
28 + };
29 + use std::io::{Read, Write};
30 + use tracing::warn;
31 + use wayland_client::protocol::{wl_data_device, wl_data_source, wl_surface};
14 32
15 33 /// What we offer a paster, best first.
16 34 ///
@@ -73,6 +91,332 @@
73 91 out
74 92 }
75 93
94 + // Shop is a paste target and a copy source, and never a drag-and-drop one, so
95 + // every DnD callback below is deliberately empty rather than unimplemented:
96 + // the traits carry both jobs and we only do one of them.
97 +
98 + impl DataDeviceHandler for App {
99 + fn enter(
100 + &mut self,
101 + _: &Connection,
102 + _: &QueueHandle<Self>,
103 + _: &wl_data_device::WlDataDevice,
104 + _: f64,
105 + _: f64,
106 + _: &wl_surface::WlSurface,
107 + ) {
108 + }
109 + fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_data_device::WlDataDevice) {}
110 + fn motion(
111 + &mut self,
112 + _: &Connection,
113 + _: &QueueHandle<Self>,
114 + _: &wl_data_device::WlDataDevice,
115 + _: f64,
116 + _: f64,
117 + ) {
118 + }
119 + fn selection(
120 + &mut self,
121 + _: &Connection,
122 + _: &QueueHandle<Self>,
123 + _: &wl_data_device::WlDataDevice,
124 + ) {
125 + // Someone else's clipboard is now the clipboard. Nothing to do until
126 + // a paste asks for it — the offer is read from the device then, so
127 + // holding onto it here would only risk using a stale one.
128 + }
129 + fn drop_performed(
130 + &mut self,
131 + _: &Connection,
132 + _: &QueueHandle<Self>,
133 + _: &wl_data_device::WlDataDevice,
134 + ) {
135 + }
136 + }
137 +
138 + impl DataOfferHandler for App {
139 + fn source_actions(
140 + &mut self,
141 + _: &Connection,
142 + _: &QueueHandle<Self>,
143 + _: &mut DragOffer,
144 + _: smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction,
145 + ) {
146 + }
147 + fn selected_action(
148 + &mut self,
149 + _: &Connection,
150 + _: &QueueHandle<Self>,
151 + _: &mut DragOffer,
152 + _: smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction,
153 + ) {
154 + }
155 + }
156 +
157 + impl DataSourceHandler for App {
158 + fn send_request(
159 + &mut self,
160 + _: &Connection,
161 + _: &QueueHandle<Self>,
162 + source: &wl_data_source::WlDataSource,
163 + _: String,
164 + pipe: WritePipe,
165 + ) {
166 + // Only ever our clipboard source; anything else is a stale offer we
167 + // already dropped, and answering it would send the wrong text.
168 + if self.clipboard_source.as_ref().map(CopyPasteSource::inner) != Some(source) {
169 + return;
170 + }
171 + write_selection(pipe, self.clipboard_text.as_bytes());
172 + }
173 +
174 + fn cancelled(
175 + &mut self,
176 + _: &Connection,
177 + _: &QueueHandle<Self>,
178 + source: &wl_data_source::WlDataSource,
179 + ) {
180 + // We lost the clipboard to another client. Drop the source (which
181 + // destroys it) and the text with it, so a later paste goes and asks
182 + // the new owner instead of replaying what we used to hold.
183 + if self.clipboard_source.as_ref().map(CopyPasteSource::inner) == Some(source) {
184 + self.clipboard_source = None;
185 + self.clipboard_text.clear();
186 + }
187 + }
188 +
189 + fn accept_mime(
190 + &mut self,
191 + _: &Connection,
192 + _: &QueueHandle<Self>,
193 + _: &wl_data_source::WlDataSource,
194 + _: Option<String>,
195 + ) {
196 + }
197 + fn dnd_dropped(
198 + &mut self,
199 + _: &Connection,
200 + _: &QueueHandle<Self>,
201 + _: &wl_data_source::WlDataSource,
202 + ) {
203 + }
204 + fn dnd_finished(
205 + &mut self,
206 + _: &Connection,
207 + _: &QueueHandle<Self>,
208 + _: &wl_data_source::WlDataSource,
209 + ) {
210 + }
211 + fn action(
212 + &mut self,
213 + _: &Connection,
214 + _: &QueueHandle<Self>,
215 + _: &wl_data_source::WlDataSource,
216 + _: smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction,
217 + ) {
218 + }
219 + }
220 +
221 + impl PrimarySelectionDeviceHandler for App {
222 + fn selection(
223 + &mut self,
224 + _: &Connection,
225 + _: &QueueHandle<Self>,
226 + _: &zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
227 + ) {
228 + }
229 + }
230 +
231 + impl PrimarySelectionSourceHandler for App {
232 + fn send_request(
233 + &mut self,
234 + _: &Connection,
235 + _: &QueueHandle<Self>,
236 + source: &zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
237 + _: String,
238 + pipe: WritePipe,
239 + ) {
240 + if self
241 + .primary_source
242 + .as_ref()
243 + .map(PrimarySelectionSource::inner)
244 + != Some(source)
245 + {
246 + return;
247 + }
248 + write_selection(pipe, self.primary_text.as_bytes());
249 + }
250 +
251 + fn cancelled(
252 + &mut self,
253 + _: &Connection,
254 + _: &QueueHandle<Self>,
255 + source: &zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
256 + ) {
257 + if self
258 + .primary_source
259 + .as_ref()
260 + .map(PrimarySelectionSource::inner)
261 + == Some(source)
262 + {
263 + self.primary_source = None;
264 + self.primary_text.clear();
265 + // The highlight was standing for "this is what middle-click will
266 + // paste". It no longer is, so it should stop saying so.
267 + self.selection = None;
268 + self.dirty = true;
269 + }
270 + }
271 + }
272 +
273 + /// Answer a paste request by writing the text and closing the pipe.
274 + ///
275 + /// Blocking, and safe to be: the pipe comes from the program doing the
276 + /// pasting, which is reading it, and the payload is bounded by the visible
277 + /// grid because shop has no scrollback to select out of.
278 + fn write_selection(mut pipe: WritePipe, bytes: &[u8]) {
279 + if let Err(e) = pipe.write_all(bytes) {
280 + warn!("selection send: {e}");
281 + }
282 + // Dropping the pipe closes the fd, which is what tells the far end the
283 + // text is complete. Without it a paste hangs waiting for more.
284 + drop(pipe);
285 + }
286 +
287 + impl App {
288 + /// Take ownership of the clipboard, offering `text` to whoever pastes.
289 + pub(crate) fn set_clipboard(&mut self, text: String) {
290 + let (Some(mgr), Some(device)) = (&self.data_device_manager, &self.data_device) else {
291 + return;
292 + };
293 + let source = mgr.create_copy_paste_source(&self.qh, OFFERED_MIMES);
294 + source.set_selection(device, self.last_serial);
295 + self.clipboard_text = text;
296 + // Dropping the old source destroys it, which is how the previous offer
297 + // is withdrawn.
298 + self.clipboard_source = Some(source);
299 + }
300 +
301 + /// Take ownership of the primary selection.
302 + pub(crate) fn set_primary(&mut self, text: String) {
303 + let (Some(mgr), Some(device)) = (&self.primary_manager, &self.primary_device) else {
304 + return;
305 + };
306 + let source = mgr.create_selection_source(&self.qh, OFFERED_MIMES);
307 + source.set_selection(device, self.last_serial);
308 + self.primary_text = text;
309 + self.primary_source = Some(source);
310 + }
311 +
312 + pub(crate) fn paste_clipboard(&mut self) {
313 + // We own it: answer from our own copy rather than asking the
314 + // compositor to ask us. Besides being pointless, the round trip
315 + // cannot complete — the send request would arrive on a dispatch we
316 + // are inside of.
317 + if self.clipboard_source.is_some() {
318 + let text = std::mem::take(&mut self.clipboard_text);
319 + self.write_paste(text.as_bytes());
320 + self.clipboard_text = text;
321 + return;
322 + }
323 + let Some(offer) = self
324 + .data_device
325 + .as_ref()
326 + .and_then(|device| device.data().selection_offer())
327 + else {
328 + return;
329 + };
330 + match offer.receive(MIME_UTF8.to_string()) {
331 + Ok(pipe) => self.read_paste(pipe),
332 + Err(e) => warn!("clipboard receive: {e}"),
333 + }
334 + }
335 +
336 + pub(crate) fn paste_primary(&mut self) {
337 + if self.primary_source.is_some() {
338 + let text = std::mem::take(&mut self.primary_text);
339 + self.write_paste(text.as_bytes());
340 + self.primary_text = text;
341 + return;
342 + }
343 + let Some(offer) = self
344 + .primary_device
345 + .as_ref()
346 + .and_then(|device| device.data().selection_offer())
347 + else {
348 + return;
349 + };
350 + match offer.receive(MIME_UTF8.to_string()) {
351 + Ok(pipe) => self.read_paste(pipe),
352 + Err(e) => warn!("primary receive: {e}"),
353 + }
354 + }
355 +
356 + /// Drain a paste pipe through the event loop and feed the result to the
357 + /// PTY once the far end closes it.
358 + ///
359 + /// Asynchronous on purpose. The program on the other end writes at its own
360 + /// pace, may be slow, and may be shop itself; a blocking read here would
361 + /// stop the terminal on any of those.
362 + fn read_paste(&mut self, pipe: ReadPipe) {
363 + let mut collected: Vec<u8> = Vec::new();
364 + let inserted = self.loop_handle.insert_source(pipe, move |(), file, app| {
365 + // Exactly one read per readiness, never a drain loop. The pipe
366 + // arrives blocking — SCTK creates it with CLOEXEC and nothing
367 + // else — so a second read with the writer still working would
368 + // stop the whole terminal until it caught up. Level-triggered
369 + // polling calls us back for the rest.
370 + let mut chunk = [0u8; 4096];
371 + // `&fs::File` reads without needing the mutable borrow the
372 + // metadata guard will not give up.
373 + let mut handle: &std::fs::File = file;
374 + match handle.read(&mut chunk) {
375 + Ok(0) => {
376 + app.write_paste(&collected);
377 + calloop::PostAction::Remove
378 + }
379 + Ok(n) => {
380 + collected.extend_from_slice(&chunk[..n]);
381 + calloop::PostAction::Continue
382 + }
383 + Err(e)
384 + if matches!(
385 + e.kind(),
386 + std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
387 + ) =>
388 + {
389 + calloop::PostAction::Continue
390 + }
391 + Err(e) => {
392 + warn!("paste read: {e}");
393 + calloop::PostAction::Remove
394 + }
395 + }
396 + });
397 + if let Err(e) = inserted {
398 + warn!("paste source: {e}");
399 + }
400 + }
401 +
402 + /// Hand pasted bytes to the shell, bracketed if it asked to be told.
403 + fn write_paste(&mut self, raw: &[u8]) {
404 + if raw.is_empty() {
405 + return;
406 + }
407 + let bytes = sanitize_paste(raw, self.grid.bracketed_paste());
408 + if let Err(e) = self.pty.write(&bytes) {
409 + warn!("pty write (paste): {e}");
410 + }
411 + }
412 +
413 + /// The selected text, if there is a selection with anything in it.
414 + pub(crate) fn selection_text(&self) -> Option<String> {
415 + let text = self.grid.selection_text(&self.selection?);
416 + (!text.is_empty()).then_some(text)
417 + }
418 + }
419 +
76 420 #[cfg(test)]
77 421 mod tests {
78 422 use super::*;
@@ -4,6 +4,19 @@
4 4 //! the PTY master fd. Bytes read from the shell go through `shop_vt` into a
5 5 //! `shop_grid::Grid`, which `shop_render` draws.
6 6 //!
7 + //! What is where: `cli` reads argv, `graphics` carries the kitty protocol,
8 + //! `draw` paints a frame, and `chrome`, `input`, `pointer` and `clipboard`
9 + //! hold the SCTK handlers. All four of those are impl blocks on `App` rather
10 + //! than types of their own, because SCTK puts every protocol handler on one
11 + //! state type and `App` is it.
12 + //!
13 + //! `main()` stays here whole. Two of its guarantees are properties of this
14 + //! one function: the `create_surface_unsafe` block is sound because `conn`
15 + //! and `chrome` outlive it for the rest of the call, and the tail requires
16 + //! `app` to drop before the event loop's `WaylandSource`. Extracting a
17 + //! constructor would move both into a struct's field order, where nothing
18 + //! states them.
19 + //!
7 20 //! <!-- wiki: shop-overview -->
8 21 //!
9 22 //! No status or milestone lines here. A status line in a doc comment is read on
@@ -12,61 +25,49 @@
12 25 use std::os::fd::{AsFd, AsRawFd};
13 26 use std::sync::Arc;
14 27
28 + mod chrome;
29 + mod cli;
15 30 mod clipboard;
31 + mod draw;
16 32 mod emit;
33 + mod graphics;
34 + mod input;
17 35 mod keys;
36 + mod pointer;
18 37 mod theme;
19 - use clipboard::{MIME_UTF8, OFFERED_MIMES, sanitize_paste};
20 - use keys::Action;
38 + use cli::{Cli, CliExit, parse_args};
39 + use draw::{grid_cols, grid_rows, identity, render_now};
40 + use graphics::{ParseSink, handle_kitty};
21 41 use theme::{Config, Palette};
22 42
23 43 use calloop::generic::{FdWrapper, Generic};
24 44 use calloop::{EventLoop, LoopHandle};
25 45 use calloop_wayland_source::WaylandSource;
26 46 use kittygfx as kgp;
27 - use shop_grid::{
28 - Color as GridColor, CursorShape, Grid, MouseAction, MouseButton, MouseMods, MouseReport,
29 - MouseTracking, Point, Selection, SelectionMode,
30 - };
47 + use shop_grid::{Grid, MouseButton, Point, Selection};
31 48 use shop_pty::{Pty, PtySize};
32 - use shop_render::{BgFill, CellMetrics, CellText, ImagePlacement, ImageRenderer, TextRenderer};
49 + use shop_render::{CellMetrics, ImagePlacement, ImageRenderer, TextRenderer};
33 50 use shop_wayland::{
34 - Capability, CompositorHandler, CompositorState, Connection, Layer, LayerConfig, LayerShell,
35 - LayerShellHandler, LayerSurface, LayerSurfaceConfigure, OutputHandler, OutputState, Pending,
36 - ProvidesRegistryState, QueueHandle, Region, RegistryState, SeatHandler, SeatState, ShopSurface,
37 - WaylandChrome, WaylandSurface, WindowConfigure, WindowDecorations, WindowHandler, WindowSpec,
38 - XdgShell, XdgWindow, registry_queue_init,
39 - };
40 - use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{
41 - zwp_primary_selection_device_v1, zwp_primary_selection_source_v1,
51 + CompositorState, Connection, LayerConfig, LayerShell, OutputState, Pending, QueueHandle,
52 + Region, RegistryState, SeatState, ShopSurface, WaylandChrome, WaylandSurface,
53 + WindowDecorations, WindowSpec, XdgShell, registry_queue_init,
42 54 };
43 55 use smithay_client_toolkit::{
44 56 data_device_manager::{
45 - DataDeviceManagerState, ReadPipe, WritePipe,
46 - data_device::{DataDevice, DataDeviceHandler},
47 - data_offer::{DataOfferHandler, DragOffer},
48 - data_source::{CopyPasteSource, DataSourceHandler},
57 + DataDeviceManagerState, data_device::DataDevice, data_source::CopyPasteSource,
49 58 },
50 - delegate_dispatch2, delegate_registry,
51 59 primary_selection::{
52 - PrimarySelectionManagerState,
53 - device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},
54 - selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},
60 + PrimarySelectionManagerState, device::PrimarySelectionDevice,
61 + selection::PrimarySelectionSource,
55 62 },
56 - registry_handlers,
57 - seat::keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},
58 - seat::pointer::{
59 - CursorIcon, PointerEvent, PointerEventKind, PointerHandler, ThemeSpec, ThemedPointer,
60 - },
61 - shm::{Shm, ShmHandler},
63 + seat::keyboard::Modifiers,
64 + seat::pointer::ThemedPointer,
65 + shm::Shm,
62 66 };
67 +
63 68 use std::collections::VecDeque;
64 - use std::io::{Read, Write};
65 69 use tracing::{info, warn};
66 70 use wayland_client::protocol::wl_keyboard;
67 - use wayland_client::protocol::{
68 - wl_callback, wl_data_device, wl_data_source, wl_output, wl_pointer, wl_seat, wl_surface,
69 - };
70 71
71 72 const INITIAL: (u32, u32) = (960, 540);
72 73 /// Quasi Mono, the house monospace face, cut at build time by `shop-font`.
@@ -113,181 +114,6 @@
113 114 const BTN_RIGHT: u32 = 0x111;
114 115 const BTN_MIDDLE: u32 = 0x112;
115 116
116 - /// The three buttons a terminal has a number for. Anything else on the mouse
117 - /// is not reportable, so it is left alone rather than folded into one of these.
118 - fn mouse_button(button: u32) -> Option<MouseButton> {
119 - match button {
120 - BTN_LEFT => Some(MouseButton::Left),
121 - BTN_MIDDLE => Some(MouseButton::Middle),
122 - BTN_RIGHT => Some(MouseButton::Right),
123 - _ => None,
124 - }
125 - }
126 -
127 - /// What argv asked the terminal to do, once it is known to be a request to
128 - /// open a window at all.
129 - #[derive(Debug, Default, PartialEq, Eq)]
130 - struct Cli {
131 - /// `--exec CMD` runs `sh -c CMD` instead of the interactive shell — useful
132 - /// for benchmarks and one-shot invocations. Shop exits when the child
133 - /// exits (PTY EOF triggers the read=0 branch).
134 - exec_cmd: Option<String>,
135 - /// `-e PROGRAM [ARGS...]` takes the rest of argv as a program and its
136 - /// arguments, with no shell in between. This is the xterm convention, and
137 - /// it is not optional for a terminal that means to be somebody's
138 - /// `$TERMINAL`: every desktop entry with `Terminal=true`, and every script
139 - /// that spawns a TUI, writes `$TERMINAL -e prog arg`. A terminal that
140 - /// ignores it opens a bare shell and looks like the launcher is broken.
141 - ///
142 - /// Distinct from `--exec` on purpose: that one is a single string handed to
143 - /// `sh -c`, which is what a benchmark wants and what a launcher must not
144 - /// have, because the arguments would need quoting nobody applies.
145 - exec_argv: Option<Vec<String>>,
146 - /// `--record PATH` tees the PTY output byte-for-byte to PATH, for
147 - /// feeding into the kitty-graphics-testkit corpus via capture-apc.
148 - record_path: Option<String>,
149 - /// `--theme ID` overrides the config file for one run, which is how you look
150 - /// at a theme before committing to it.
151 - theme: Option<String>,
152 - /// `--app-id ID` sets the xdg-shell app_id this window reports.
153 - ///
154 - /// What a compositor keys its window rules on. Alloy's sway config runs the
155 - /// launcher as `shop --app-id=alloy-menu -e alloy-menu` so that
156 - /// `for_window [app_id="alloy-menu"] floating enable, resize set 800 500`
157 - /// matches it, and a terminal that cannot say who it is makes that rule
158 - /// unwritable: every shop window would be the same window to sway.
159 - app_id: Option<String>,
160 - /// `--layer background|bottom|top|overlay` opens on wlr-layer-shell instead
161 - /// of as an ordinary window: sized to the output, anchored to all four
162 - /// edges, and taking no input at all.
163 - ///
164 - /// What Alloy's desktop background is. The surface never takes keyboard or
165 - /// pointer focus, so managing the instance is somebody else's job; a
166 - /// background that swallowed clicks across the whole output would be worse
167 - /// than no background.
168 - layer: Option<Layer>,
169 - }
170 -
171 - /// `--layer` values, spelled as the protocol names them.
172 - fn parse_layer(value: &str) -> Result<Layer, CliExit> {
173 - match value {
174 - "background" => Ok(Layer::Background),
175 - "bottom" => Ok(Layer::Bottom),
176 - "top" => Ok(Layer::Top),
177 - "overlay" => Ok(Layer::Overlay),
178 - other => Err(CliExit::Reject(format!(
179 - "unknown layer {other}\nExpected background, bottom, top or overlay."
180 - ))),
181 - }
182 - }
183 -
184 - /// Every way argv can end the process before a window exists.
185 - #[derive(Debug, PartialEq, Eq)]
186 - enum CliExit {
187 - /// Asked a question we can answer on stdout. Exit 0.
188 - Answer(String),
189 - /// Asked for something we do not have. Exit 2, message on stderr.
190 - Reject(String),
191 - }
192 -
193 - const HELP: &str = "\
194 - shop, a Wayland terminal emulator.
195 -
196 - Usage: shop [OPTIONS] [-e PROGRAM [ARGS...]]
197 -
198 - Options:
199 - -e PROGRAM [ARGS...] Run PROGRAM with no shell in between. Takes the rest
200 - of the command line, so it goes last.
201 - --exec CMD Run CMD through sh -c instead of the login shell.
202 - --theme ID Use theme ID for this run, ignoring the config file.
203 - --app-id ID Report ID as the window's app id, for window rules.
204 - --layer LAYER Open on the wlr-layer-shell layer LAYER, one of
205 - background, bottom, top or overlay, sized to the
206 - output and taking no input. Needs a compositor with
207 - wlr-layer-shell.
208 - --record PATH Tee the terminal output to PATH as raw bytes.
209 - -h, --help Print this help.
210 - -V, --version Print the version.
211 - --license Print the bundled font's licence.
212 -
213 - Options take their value either way: --theme dark or --theme=dark.
214 -
215 - Config is read from ~/.config/shop/config.toml.";
216 -
217 - /// Read argv, or say why we are not opening a window.
218 - ///
219 - /// Written by hand rather than with a parser crate because the grammar is four
220 - /// flags and one convention, and the one convention is the part a crate gets
221 - /// wrong: `-e` swallows the remainder of the command line, child flags and all,
222 - /// so nothing after it is ours to interpret.
223 - ///
224 - /// The rejection half is the point. Silently ignoring an unknown flag means
225 - /// `shop --version` opens a terminal, and a typo'd `--theme` in a desktop entry
226 - /// looks like the theme is broken rather than like the entry is.
227 - fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, CliExit> {
228 - let mut cli = Cli::default();
229 - let mut args = args.into_iter().skip(1);
230 - while let Some(arg) = args.next() {
231 - // Takes the rest verbatim, so stop reading argv as ours here. Empty is
232 - // not an error: `-e` with nothing after it is a launcher that built a
233 - // command line and found no command, and a shell is the useful answer.
234 - if arg == "-e" {
235 - let argv: Vec<String> = args.by_ref().collect();
236 - cli.exec_argv = Some(argv).filter(|a| !a.is_empty());
237 - return Ok(cli);
238 - }
239 - // `--flag=value` as well as `--flag value`. Both spellings are ordinary
240 - // and callers pick whichever reads better, so a parser that knows only
241 - // one of them rejects correct command lines. Alloy's sway config writes
242 - // the equals form, and understanding only the space form is exactly how
243 - // the launcher broke.
244 - let (name, inline) = match arg.split_once('=') {
245 - Some((name, value)) if name.starts_with("--") => (name, Some(value.to_string())),
246 - _ => (arg.as_str(), None),
247 - };
248 - let mut value = |flag: &str| match &inline {
249 - Some(value) => Ok(value.clone()),
250 - None => args
251 - .next()
252 - .ok_or_else(|| CliExit::Reject(format!("{flag} needs a value"))),
253 - };
254 - match name {
255 - "-h" | "--help" | "-V" | "--version" | "--license" if inline.is_some() => {
256 - return Err(CliExit::Reject(format!("{name} takes no value")));
257 - }
258 - "-h" | "--help" => return Err(CliExit::Answer(HELP.into())),
259 - "-V" | "--version" => {
260 - return Err(CliExit::Answer(format!(
261 - "shop {}",
262 - env!("CARGO_PKG_VERSION")
263 - )));
264 - }
265 - // OFL 1.1 requires the licence to travel with a modified build, and
266 - // the bundled face is one. It lives in the binary rather than in a
267 - // repo file, so it reaches anyone holding only the binary.
268 - "--license" => {
269 - return Err(CliExit::Answer(format!(
270 - "shop bundles {} {}, cut from the house glyph set.\n\n{}",
271 - shop_font::FAMILY,
272 - shop_font::DEFAULT_STYLE,
273 - shop_font::LICENSE
274 - )));
275 - }
276 - "--exec" => cli.exec_cmd = Some(value("--exec")?),
277 - "--theme" => cli.theme = Some(value("--theme")?),
278 - "--record" => cli.record_path = Some(value("--record")?),
279 - "--app-id" => cli.app_id = Some(value("--app-id")?),
280 - "--layer" => cli.layer = Some(parse_layer(&value("--layer")?)?),
281 - other => {
282 - return Err(CliExit::Reject(format!(
283 - "unknown option {other}\nTry 'shop --help'."
284 - )));
285 - }
286 - }
287 - }
288 - Ok(cli)
289 - }
290 -
291 117 fn main() -> anyhow::Result<()> {
292 118 // Before the subscriber, so `shop --help` prints help and nothing else.
293 119 let cli = match parse_args(std::env::args()) {
@@ -729,185 +555,6 @@
729 555 Ok(())
730 556 }
731 557
732 - /// Composite Perform: forwards the mainline callbacks to `Grid` and pushes
733 - /// APC bodies onto a separate queue. shop-vt exposes an `apc_dispatch` that
734 - /// `vte` off crates.io hides, which is what makes this crate hookup possible
735 - /// without a byte-stream pre-scanner.
736 - struct ParseSink<'a> {
737 - grid: &'a mut Grid,
738 - apc: &'a mut Vec<Vec<u8>>,
739 - }
740 -
741 - impl shop_vt::Perform for ParseSink<'_> {
742 - fn print(&mut self, c: char) {
743 - <Grid as shop_vt::Perform>::print(self.grid, c);
744 - }
745 - fn execute(&mut self, byte: u8) {
746 - <Grid as shop_vt::Perform>::execute(self.grid, byte);
747 - }
748 - fn csi_dispatch(
749 - &mut self,
750 - params: &shop_vt::Params,
751 - intermediates: &[u8],
752 - ignore: bool,
753 - action: char,
754 - ) {
755 - <Grid as shop_vt::Perform>::csi_dispatch(self.grid, params, intermediates, ignore, action);
756 - }
757 - fn esc_dispatch(&mut self, intermediates: &[u8], ignore: bool, byte: u8) {
758 - <Grid as shop_vt::Perform>::esc_dispatch(self.grid, intermediates, ignore, byte);
759 - }
760 - fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) {
761 - <Grid as shop_vt::Perform>::osc_dispatch(self.grid, params, bell_terminated);
762 - }
763 - fn apc_dispatch(&mut self, data: &[u8]) {
764 - self.apc.push(data.to_vec());
765 - }
766 - }
767 -
768 - // Image dimensions are w/h throughout the kitty protocol; spelling them out
769 - // here would not match the spec being implemented.
770 - #[allow(clippy::many_single_char_names)]
771 - fn handle_kitty(app: &mut App, cmd: kgp::Command) {
772 - match cmd {
773 - kgp::Command::Transmit { control, payload } => {
774 - let Some(format) = control.format else {
775 - warn!("kitty: transmit missing format");
776 - return;
777 - };
778 - let rgba = match format {
779 - kgp::Format::Png => {
780 - match image::load_from_memory_with_format(&payload, image::ImageFormat::Png) {
781 - Ok(img) => {
782 - let rgba = img.to_rgba8();
783 - let (w, h) = (rgba.width(), rgba.height());
784 - Some((rgba.into_raw(), w, h))
785 - }
786 - Err(e) => {
787 - warn!("kitty: png decode: {e}");
788 - None
789 - }
790 - }
791 - }
792 - kgp::Format::Rgba => {
793 - let (w, h) = (
794 - control.width_px.unwrap_or(0),
795 - control.height_px.unwrap_or(0),
796 - );
797 - if w == 0 || h == 0 {
798 - warn!("kitty: rgba transmit missing s= / v=");
799 - None
800 - } else {
801 - Some((payload, w, h))
802 - }
803 - }
804 - kgp::Format::Rgb => {
805 - let (w, h) = (
806 - control.width_px.unwrap_or(0),
807 - control.height_px.unwrap_or(0),
808 - );
809 - if w == 0 || h == 0 {
810 - warn!("kitty: rgb transmit missing s= / v=");
811 - None
812 - } else {
813 - let mut rgba = Vec::with_capacity((w * h * 4) as usize);
814 - for chunk in payload.chunks_exact(3) {
815 - rgba.extend_from_slice(chunk);
816 - rgba.push(0xff);
817 - }
818 - Some((rgba, w, h))
819 - }
820 - }
821 - };
822 - let Some((rgba, w, h)) = rgba else {
823 - return;
824 - };
825 - let image_id = control.id.unwrap_or_else(|| {
826 - let id = app.next_anon_image_id;
827 - app.next_anon_image_id = app.next_anon_image_id.wrapping_add(1);
828 - id
829 - });
830 - app.images
831 - .set_image(&app.device, &app.queue, image_id, &rgba, w, h);
832 -
833 - if control.action == 'T' {
834 - let s = app.scale as f32;
835 - let cursor = app.grid.cursor();
836 - let x = (PAD_X + cursor.col as f32 * app.cell.advance) * s;
837 - let y = (PAD_Y + cursor.row as f32 * app.cell.height) * s;
838 - // Prefer the cell-sized placement (`c=`, `r=`) if given; fall
839 - // back to the source pixel dims scaled up. yazi always sends
840 - // `c` and `r`.
841 - let placement_w = control
842 - .cell_cols
843 - .map_or(w as f32, |c| c as f32 * app.cell.advance * s);
844 - let placement_h = control
845 - .cell_rows
846 - .map_or(h as f32, |r| r as f32 * app.cell.height * s);
847 - app.image_placement = Some(ImagePlacement {
848 - image_id,
849 - x,
850 - y,
851 - w: placement_w,
852 - h: placement_h,
853 - });
854 - }
855 - }
856 - kgp::Command::Delete { .. } => {
857 - app.images.drop_all();
858 - app.image_placement = None;
859 - }
860 - // "Can you draw this?" Answered rather than ignored, because that is
861 - // how a terminal nothing has heard of gets its graphics support
862 - // noticed: clients match a list of terminal names first and fall back
863 - // to asking when the name means nothing to them. yazi is the one that
864 - // matters here, and it asks precisely because shop is in no list.
865 - kgp::Command::Query { control } => {
866 - let reply = kgp::query_response(&control);
867 - if let Err(e) = app.pty.write(&reply) {
868 - warn!("pty write (kitty query): {e}");
869 - }
870 - }
871 - // Multi-image placement + animation are parsed but not yet rendered
872 - // (shop tasks 509fd8cb, e61fb2a1). Dropping them here matches the
873 - // current single-image renderer.
874 - kgp::Command::Place { .. }
875 - | kgp::Command::FrameAppend { .. }
876 - | kgp::Command::FrameCompose { .. } => {}
877 - }
878 - }
879 -
880 - /// The cell under a surface-local position.
881 - ///
882 - /// Positions arrive in logical pixels, which is also what the padding and the
883 - /// cell are in, so scale does not enter into this. Clamped rather than
884 - /// optional: a pointer out in the padding is treated as the nearest cell,
885 - /// which is what makes dragging off the edge of the window select to the end
886 - /// of the line instead of stopping dead.
887 - fn cell_at((x, y): (f64, f64), cols: u16, rows: u16, cell: CellMetrics) -> Point {
888 - let col = ((x - f64::from(PAD_X)) / f64::from(cell.advance)).floor();
889 - let row = ((y - f64::from(PAD_Y)) / f64::from(cell.height)).floor();
890 - let to_index = |v: f64, count: u16| v.clamp(0.0, f64::from(count.saturating_sub(1))) as u16;
891 - Point::new(to_index(row, rows), to_index(col, cols))
892 - }
893 -
894 - /// Granularity counter for a press: 1 char, 2 word, 3 line.
895 - ///
896 - /// Climbs only for repeat presses in the same cell inside the double-click
897 - /// interval, and wraps, so a fourth click starts over at char granularity
898 - /// rather than sticking on whole lines.
899 - fn next_click_count(previous: Option<(u32, Point)>, count: u32, time: u32, at: Point) -> u32 {
900 - match previous {
901 - // wrapping_sub because the compositor's millisecond clock has an
902 - // arbitrary origin and is free to wrap; the difference stays right
903 - // across the wrap even though the operands don't.
904 - Some((last, cell)) if cell == at && time.wrapping_sub(last) < MULTI_CLICK_MS => {
905 - count % 3 + 1
906 - }
907 - _ => 1,
908 - }
909 - }
910 -
911 558 /// What to spawn on the PTY, given the two exec flags and `$SHELL`.
912 559 ///
913 560 /// `-e` wins over `--exec`: it is the one a launcher passes, so if both
@@ -931,46 +578,6 @@
931 578 }
932 579 }
933 580
934 - /// What the grid should answer about the terminal, at the current scale.
935 - ///
936 - /// Cell size is physical pixels, so it moves with the output scale and this
937 - /// has to be recomputed whenever that changes. The colours come from the
938 - /// theme, which is the only thing that knows them: a program asking OSC 11
939 - /// whether it is on a light or a dark terminal gets the wrong answer from
940 - /// anything else.
941 - fn identity(palette: &Palette, scale: u32, cell: CellMetrics) -> shop_grid::Identity {
942 - let s = scale.max(1) as f32;
943 - shop_grid::Identity {
944 - name: "shop".into(),
945 - version: env!("CARGO_PKG_VERSION").into(),
946 - cell_px: (
947 - (cell.advance * s).round() as u16,
948 - (cell.height * s).round() as u16,
949 - ),
950 - fg: srgb_bytes(palette.fg),
951 - bg: srgb_bytes(palette.bg),
952 - }
953 - }
954 -
955 - /// A palette colour back as the three bytes the theme file spelled it with.
956 - fn srgb_bytes([r, g, b, _]: [f32; 4]) -> [u8; 3] {
957 - [
958 - (r * 255.0).round() as u8,
959 - (g * 255.0).round() as u8,
960 - (b * 255.0).round() as u8,
961 - ]
962 - }
963 -
Lines truncated
@@ -1,0 +1,232 @@
1 + //! Window furniture: the compositor, output, shm, xdg-toplevel and layer-shell
2 + //! handlers, plus the registry delegation that routes their events.
3 + //!
4 + //! SCTK puts every protocol handler on one state type, so these are impl blocks
5 + //! on `App` rather than a type of their own. The two delegate macros expand to
6 + //! further impls on `App` and belong with `ProvidesRegistryState`: separated,
7 + //! the registry delegation loses its handler.
8 +
9 + use crate::{App, FONT_PX, render_now};
10 + use shop_render::TextRenderer;
11 + use shop_wayland::{
12 + CompositorHandler, Connection, LayerShellHandler, LayerSurface, LayerSurfaceConfigure,
13 + OutputHandler, OutputState, Pending, ProvidesRegistryState, QueueHandle, RegistryState,
14 + WindowConfigure, WindowHandler, XdgWindow,
15 + };
16 + use smithay_client_toolkit::shm::{Shm, ShmHandler};
17 + use smithay_client_toolkit::{delegate_dispatch2, delegate_registry, registry_handlers};
18 + use wayland_client::protocol::{wl_callback, wl_output, wl_surface};
19 +
20 + /// The scale shop will render at, given what the compositor just said.
21 + ///
22 + /// Clamped at both ends. `wl_surface.set_buffer_scale` is defined for positive
23 + /// integers only, so a zero or negative factor is not a scale to honour, and
24 + /// the ceiling bounds the buffer a rebuild allocates rather than trusting the
25 + /// number to be sane.
26 + fn clamp_scale(new_factor: i32) -> u32 {
27 + (new_factor.max(1) as u32).min(8)
28 + }
29 +
30 + impl CompositorHandler for App {
31 + fn scale_factor_changed(
32 + &mut self,
33 + _: &Connection,
34 + _: &QueueHandle<Self>,
35 + _: &wl_surface::WlSurface,
36 + new_factor: i32,
37 + ) {
38 + let new_scale = clamp_scale(new_factor);
39 + if new_scale == self.scale {
40 + return;
41 + }
42 + self.scale = new_scale;
43 + match TextRenderer::new(
44 + &self.device,
45 + &self.queue,
46 + self.surface_format,
47 + self.font_data.clone(),
48 + FONT_PX * new_scale as f32,
49 + self.cell.scaled(new_scale),
50 + shop_font::WEIGHT,
51 + ) {
52 + Ok(t) => self.text = t,
53 + Err(e) => {
54 + tracing::warn!("rebuild text at scale {new_scale}: {e:?}");
55 + return;
56 + }
57 + }
58 + // The new renderer has an empty per-row cache and the grid has not
59 + // changed, so without this nothing would ever name a row to rebuild.
60 + // The resize queued below does not cover it: the logical dimensions are
61 + // the same ones, and `Grid::resize` returns early when they are.
62 + self.grid.invalidate_render();
63 + // Re-queue a resize with the current logical dims so the surface
64 + // reconfigures at the new physical resolution and set_buffer_scale
65 + // fires.
66 + self.chrome.pending.push_back(Pending::Resized {
67 + width: self.chrome.width,
68 + height: self.chrome.height,
69 + });
70 + }
71 + fn transform_changed(
72 + &mut self,
73 + _: &Connection,
74 + _: &QueueHandle<Self>,
75 + _: &wl_surface::WlSurface,
76 + _: wl_output::Transform,
77 + ) {
78 + }
79 + fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {
80 + // SCTK doesn't actually forward the raw wl_callback done event here;
81 + // our own Dispatch<WlCallback, ()> impl below is what drives the
82 + // coalesce loop. Left in place because CompositorHandler requires it.
83 + }
84 + fn surface_enter(
85 + &mut self,
86 + _: &Connection,
87 + _: &QueueHandle<Self>,
88 + _: &wl_surface::WlSurface,
89 + _: &wl_output::WlOutput,
90 + ) {
91 + }
92 + fn surface_leave(
93 + &mut self,
94 + _: &Connection,
95 + _: &QueueHandle<Self>,
96 + _: &wl_surface::WlSurface,
97 + _: &wl_output::WlOutput,
98 + ) {
99 + }
100 + }
101 +
102 + impl OutputHandler for App {
103 + fn output_state(&mut self) -> &mut OutputState {
104 + &mut self.chrome.output
105 + }
106 + fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
107 + fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
108 + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
109 + }
110 +
111 + impl ShmHandler for App {
112 + fn shm_state(&mut self) -> &mut Shm {
113 + // Unreachable without a binding: SCTK routes wl_shm events to the
114 + // wl_shm `Shm::bind` created, so if the bind failed there is no object
115 + // for an event to arrive on.
116 + self.shm.as_mut().expect("wl_shm event without a wl_shm")
117 + }
118 + }
119 +
120 + impl WindowHandler for App {
121 + fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &XdgWindow) {
122 + self.chrome.exit = true;
123 + self.chrome.pending.push_back(Pending::CloseRequested);
124 + }
125 +
126 + fn configure(
127 + &mut self,
128 + _: &Connection,
129 + _: &QueueHandle<Self>,
130 + _: &XdgWindow,
131 + configure: WindowConfigure,
132 + _serial: u32,
133 + ) {
134 + let (new_w, new_h) = configure.new_size;
135 + let width = new_w.map_or(self.chrome.width, std::num::NonZeroU32::get);
136 + let height = new_h.map_or(self.chrome.height, std::num::NonZeroU32::get);
137 + self.chrome.width = width;
138 + self.chrome.height = height;
139 + self.chrome
140 + .pending
141 + .push_back(Pending::Resized { width, height });
142 + }
143 + }
144 +
145 + impl LayerShellHandler for App {
146 + fn closed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &LayerSurface) {
147 + self.chrome.exit = true;
148 + self.chrome.pending.push_back(Pending::CloseRequested);
149 + }
150 +
151 + fn configure(
152 + &mut self,
153 + _: &Connection,
154 + _: &QueueHandle<Self>,
155 + _: &LayerSurface,
156 + configure: LayerSurfaceConfigure,
157 + _serial: u32,
158 + ) {
159 + // Zero on an axis means the compositor left the choice to us, so keep
160 + // what we already have rather than collapsing the surface.
161 + let (new_w, new_h) = configure.new_size;
162 + let width = if new_w == 0 { self.chrome.width } else { new_w };
163 + let height = if new_h == 0 {
164 + self.chrome.height
165 + } else {
166 + new_h
167 + };
168 + self.chrome.width = width;
169 + self.chrome.height = height;
170 + self.chrome
171 + .pending
172 + .push_back(Pending::Resized { width, height });
173 + }
174 + }
175 +
176 + impl ProvidesRegistryState for App {
177 + fn registry(&mut self) -> &mut RegistryState {
178 + &mut self.chrome.registry
179 + }
180 + registry_handlers![OutputState];
181 + }
182 +
183 + delegate_registry!(App);
184 + delegate_dispatch2!(App);
185 +
186 + /// `wl_surface.frame` done event. Compositor signalling us that it's ready
187 + /// for the next frame — if the grid has dirtied since we submitted the last
188 + /// one, render immediately. Otherwise wait for content to arrive (a PTY
189 + /// read will kick the loop back off via the main-loop dirty-check).
190 + impl wayland_client::Dispatch<wl_callback::WlCallback, ()> for App {
191 + fn event(
192 + state: &mut Self,
193 + _: &wl_callback::WlCallback,
194 + event: wl_callback::Event,
195 + (): &(),
196 + _: &Connection,
197 + _: &QueueHandle<Self>,
198 + ) {
199 + if let wl_callback::Event::Done { .. } = event {
200 + state.awaiting_frame = false;
201 + if state.dirty {
202 + render_now(state);
203 + }
204 + }
205 + }
206 + }
207 +
208 + #[cfg(test)]
209 + mod tests {
210 + use super::*;
211 +
212 + #[test]
213 + fn a_scale_the_protocol_cannot_mean_becomes_one() {
214 + assert_eq!(clamp_scale(0), 1);
215 + assert_eq!(clamp_scale(-3), 1);
216 + assert_eq!(clamp_scale(i32::MIN), 1);
217 + }
218 +
219 + #[test]
220 + fn the_ordinary_factors_pass_through() {
221 + assert_eq!(clamp_scale(1), 1);
222 + assert_eq!(clamp_scale(2), 2);
223 + assert_eq!(clamp_scale(3), 3);
224 + }
225 +
226 + #[test]
227 + fn nothing_past_the_ceiling_gets_through() {
228 + assert_eq!(clamp_scale(8), 8);
229 + assert_eq!(clamp_scale(9), 8);
230 + assert_eq!(clamp_scale(i32::MAX), 8);
231 + }
232 + }
@@ -1,0 +1,349 @@
1 + //! Reading argv: what the terminal was asked to do, before a window exists.
2 + //!
3 + //! Nothing here touches the Wayland stack or the grid, so the whole grammar is
4 + //! exercised by ordinary unit tests with no compositor in the room.
5 +
6 + use shop_wayland::Layer;
7 +
8 + /// What argv asked the terminal to do, once it is known to be a request to
9 + /// open a window at all.
10 + #[derive(Debug, Default, PartialEq, Eq)]
11 + pub(crate) struct Cli {
12 + /// `--exec CMD` runs `sh -c CMD` instead of the interactive shell — useful
13 + /// for benchmarks and one-shot invocations. Shop exits when the child
14 + /// exits (PTY EOF triggers the read=0 branch).
15 + pub(crate) exec_cmd: Option<String>,
16 + /// `-e PROGRAM [ARGS...]` takes the rest of argv as a program and its
17 + /// arguments, with no shell in between. This is the xterm convention, and
18 + /// it is not optional for a terminal that means to be somebody's
19 + /// `$TERMINAL`: every desktop entry with `Terminal=true`, and every script
20 + /// that spawns a TUI, writes `$TERMINAL -e prog arg`. A terminal that
21 + /// ignores it opens a bare shell and looks like the launcher is broken.
22 + ///
23 + /// Distinct from `--exec` on purpose: that one is a single string handed to
24 + /// `sh -c`, which is what a benchmark wants and what a launcher must not
25 + /// have, because the arguments would need quoting nobody applies.
26 + pub(crate) exec_argv: Option<Vec<String>>,
27 + /// `--record PATH` tees the PTY output byte-for-byte to PATH, for
28 + /// feeding into the kitty-graphics-testkit corpus via capture-apc.
29 + pub(crate) record_path: Option<String>,
30 + /// `--theme ID` overrides the config file for one run, which is how you look
31 + /// at a theme before committing to it.
32 + pub(crate) theme: Option<String>,
33 + /// `--app-id ID` sets the xdg-shell app_id this window reports.
34 + ///
35 + /// What a compositor keys its window rules on. Alloy's sway config runs the
36 + /// launcher as `shop --app-id=alloy-menu -e alloy-menu` so that
37 + /// `for_window [app_id="alloy-menu"] floating enable, resize set 800 500`
38 + /// matches it, and a terminal that cannot say who it is makes that rule
39 + /// unwritable: every shop window would be the same window to sway.
40 + pub(crate) app_id: Option<String>,
41 + /// `--layer background|bottom|top|overlay` opens on wlr-layer-shell instead
42 + /// of as an ordinary window: sized to the output, anchored to all four
43 + /// edges, and taking no input at all.
44 + ///
45 + /// What Alloy's desktop background is. The surface never takes keyboard or
46 + /// pointer focus, so managing the instance is somebody else's job; a
47 + /// background that swallowed clicks across the whole output would be worse
48 + /// than no background.
49 + pub(crate) layer: Option<Layer>,
50 + }
51 +
52 + /// `--layer` values, spelled as the protocol names them.
53 + fn parse_layer(value: &str) -> Result<Layer, CliExit> {
54 + match value {
55 + "background" => Ok(Layer::Background),
56 + "bottom" => Ok(Layer::Bottom),
57 + "top" => Ok(Layer::Top),
58 + "overlay" => Ok(Layer::Overlay),
59 + other => Err(CliExit::Reject(format!(
60 + "unknown layer {other}\nExpected background, bottom, top or overlay."
61 + ))),
62 + }
63 + }
64 +
65 + /// Every way argv can end the process before a window exists.
66 + #[derive(Debug, PartialEq, Eq)]
67 + pub(crate) enum CliExit {
68 + /// Asked a question we can answer on stdout. Exit 0.
69 + Answer(String),
70 + /// Asked for something we do not have. Exit 2, message on stderr.
71 + Reject(String),
72 + }
73 +
74 + const HELP: &str = "\
75 + shop, a Wayland terminal emulator.
76 +
77 + Usage: shop [OPTIONS] [-e PROGRAM [ARGS...]]
78 +
79 + Options:
80 + -e PROGRAM [ARGS...] Run PROGRAM with no shell in between. Takes the rest
81 + of the command line, so it goes last.
82 + --exec CMD Run CMD through sh -c instead of the login shell.
83 + --theme ID Use theme ID for this run, ignoring the config file.
84 + --app-id ID Report ID as the window's app id, for window rules.
85 + --layer LAYER Open on the wlr-layer-shell layer LAYER, one of
86 + background, bottom, top or overlay, sized to the
87 + output and taking no input. Needs a compositor with
88 + wlr-layer-shell.
89 + --record PATH Tee the terminal output to PATH as raw bytes.
90 + -h, --help Print this help.
91 + -V, --version Print the version.
92 + --license Print the bundled font's licence.
93 +
94 + Options take their value either way: --theme dark or --theme=dark.
95 +
96 + Config is read from ~/.config/shop/config.toml.";
97 +
98 + /// Read argv, or say why we are not opening a window.
99 + ///
100 + /// Written by hand rather than with a parser crate because the grammar is four
101 + /// flags and one convention, and the one convention is the part a crate gets
102 + /// wrong: `-e` swallows the remainder of the command line, child flags and all,
103 + /// so nothing after it is ours to interpret.
104 + ///
105 + /// The rejection half is the point. Silently ignoring an unknown flag means
106 + /// `shop --version` opens a terminal, and a typo'd `--theme` in a desktop entry
107 + /// looks like the theme is broken rather than like the entry is.
108 + pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, CliExit> {
109 + let mut cli = Cli::default();
110 + let mut args = args.into_iter().skip(1);
111 + while let Some(arg) = args.next() {
112 + // Takes the rest verbatim, so stop reading argv as ours here. Empty is
113 + // not an error: `-e` with nothing after it is a launcher that built a
114 + // command line and found no command, and a shell is the useful answer.
115 + if arg == "-e" {
116 + let argv: Vec<String> = args.by_ref().collect();
117 + cli.exec_argv = Some(argv).filter(|a| !a.is_empty());
118 + return Ok(cli);
119 + }
120 + // `--flag=value` as well as `--flag value`. Both spellings are ordinary
121 + // and callers pick whichever reads better, so a parser that knows only
122 + // one of them rejects correct command lines. Alloy's sway config writes
123 + // the equals form, and understanding only the space form is exactly how
124 + // the launcher broke.
125 + let (name, inline) = match arg.split_once('=') {
126 + Some((name, value)) if name.starts_with("--") => (name, Some(value.to_string())),
127 + _ => (arg.as_str(), None),
128 + };
129 + let mut value = |flag: &str| match &inline {
130 + Some(value) => Ok(value.clone()),
131 + None => args
132 + .next()
133 + .ok_or_else(|| CliExit::Reject(format!("{flag} needs a value"))),
134 + };
135 + match name {
136 + "-h" | "--help" | "-V" | "--version" | "--license" if inline.is_some() => {
137 + return Err(CliExit::Reject(format!("{name} takes no value")));
138 + }
139 + "-h" | "--help" => return Err(CliExit::Answer(HELP.into())),
140 + "-V" | "--version" => {
141 + return Err(CliExit::Answer(format!(
142 + "shop {}",
143 + env!("CARGO_PKG_VERSION")
144 + )));
145 + }
146 + // OFL 1.1 requires the licence to travel with a modified build, and
147 + // the bundled face is one. It lives in the binary rather than in a
148 + // repo file, so it reaches anyone holding only the binary.
149 + "--license" => {
150 + return Err(CliExit::Answer(format!(
151 + "shop bundles {} {}, cut from the house glyph set.\n\n{}",
152 + shop_font::FAMILY,
153 + shop_font::DEFAULT_STYLE,
154 + shop_font::LICENSE
155 + )));
156 + }
157 + "--exec" => cli.exec_cmd = Some(value("--exec")?),
158 + "--theme" => cli.theme = Some(value("--theme")?),
159 + "--record" => cli.record_path = Some(value("--record")?),
160 + "--app-id" => cli.app_id = Some(value("--app-id")?),
161 + "--layer" => cli.layer = Some(parse_layer(&value("--layer")?)?),
162 + other => {
163 + return Err(CliExit::Reject(format!(
164 + "unknown option {other}\nTry 'shop --help'."
165 + )));
166 + }
167 + }
168 + }
169 + Ok(cli)
170 + }
171 +
172 + #[cfg(test)]
173 + mod tests {
174 + use super::*;
175 +
176 + fn argv(items: &[&str]) -> Vec<String> {
177 + items.iter().map(|s| (*s).to_string()).collect()
178 + }
179 +
180 + /// argv as the process actually receives it, program name and all.
181 + fn cmdline(rest: &[&str]) -> Vec<String> {
182 + std::iter::once("shop".to_string())
183 + .chain(rest.iter().copied().map(String::from))
184 + .collect()
185 + }
186 +
187 + #[test]
188 + fn a_bare_invocation_asks_for_nothing() {
189 + assert_eq!(parse_args(cmdline(&[])), Ok(Cli::default()));
190 + }
191 +
192 + #[test]
193 + fn help_and_version_answer_instead_of_opening_a_window() {
194 + // The defect this exists for: both used to fall through the argv scan
195 + // and spawn a shell, so `shop --version` opened a terminal.
196 + for flag in ["-h", "--help"] {
197 + assert_eq!(
198 + parse_args(cmdline(&[flag])),
199 + Err(CliExit::Answer(HELP.into()))
200 + );
201 + }
202 + let version = Err(CliExit::Answer(format!(
203 + "shop {}",
204 + env!("CARGO_PKG_VERSION")
205 + )));
206 + for flag in ["-V", "--version"] {
207 + assert_eq!(parse_args(cmdline(&[flag])), version);
208 + }
209 + }
210 +
211 + #[test]
212 + fn the_layer_flag_takes_the_four_protocol_names() {
213 + for (name, want) in [
214 + ("background", Layer::Background),
215 + ("bottom", Layer::Bottom),
216 + ("top", Layer::Top),
217 + ("overlay", Layer::Overlay),
218 + ] {
219 + let cli = parse_args(cmdline(&["--layer", name])).expect("a layer we have");
220 + assert_eq!(cli.layer, Some(want));
221 + }
222 + assert_eq!(
223 + parse_args(cmdline(&["--layer=overlay"]))
224 + .expect("the equals spelling too")
225 + .layer,
226 + Some(Layer::Overlay)
227 + );
228 + }
229 +
230 + #[test]
231 + fn an_unknown_layer_is_refused_by_name() {
232 + let Err(CliExit::Reject(message)) = parse_args(cmdline(&["--layer", "wallpaper"])) else {
233 + panic!("an unknown layer must not open a window");
234 + };
235 + assert!(message.contains("wallpaper"), "{message}");
236 + }
237 +
238 + #[test]
239 + fn nothing_opens_on_a_layer_by_default() {
240 + assert_eq!(parse_args(cmdline(&[])).expect("no args").layer, None);
241 + }
242 +
243 + #[test]
244 + fn an_unknown_option_is_refused_by_name() {
245 + let Err(CliExit::Reject(message)) = parse_args(cmdline(&["--colour", "red"])) else {
246 + panic!("an unknown option must not open a window");
247 + };
248 + assert!(message.contains("--colour"), "{message}");
249 + }
250 +
251 + #[test]
252 + fn a_flag_with_no_value_is_refused() {
253 + for flag in ["--exec", "--theme", "--record"] {
254 + let Err(CliExit::Reject(message)) = parse_args(cmdline(&[flag])) else {
255 + panic!("{flag} without a value must not open a window");
256 + };
257 + assert!(message.contains(flag), "{message}");
258 + }
259 + }
260 +
261 + #[test]
262 + fn the_value_flags_read_their_values() {
263 + let cli = parse_args(cmdline(&[
264 + "--theme",
265 + "akari-night",
266 + "--exec",
267 + "ls | wc -l",
268 + "--record",
269 + "/tmp/out.bin",
270 + ]))
271 + .expect("every flag here is one we have");
272 + assert_eq!(cli.theme.as_deref(), Some("akari-night"));
273 + assert_eq!(cli.exec_cmd.as_deref(), Some("ls | wc -l"));
274 + assert_eq!(cli.record_path.as_deref(), Some("/tmp/out.bin"));
275 + assert_eq!(cli.exec_argv, None);
276 + }
277 +
278 + #[test]
279 + fn dash_e_takes_the_rest_of_the_line_including_flags_we_know() {
280 + // The reason parsing stops at -e rather than continuing: --theme here
281 + // is helix's argument, not ours, and rejecting or eating it would
282 + // break every `$TERMINAL -e prog --flag` in the desktop.
283 + let cli = parse_args(cmdline(&["-e", "helix", "--theme", "dark", "f.rs"]))
284 + .expect("-e swallows the remainder");
285 + assert_eq!(
286 + cli.exec_argv,
287 + Some(argv(&["helix", "--theme", "dark", "f.rs"]))
288 + );
289 + assert_eq!(cli.theme, None);
290 + }
291 +
292 + // The exact line in Alloy's sway config, and the regression that put it
293 + // here: rejecting unknown options turned a flag shop silently ignored into
294 + // one that exited 2, so $mod+d stopped opening anything at all.
295 + #[test]
296 + fn the_launcher_command_line_parses() {
297 + let cli = parse_args(cmdline(&[
298 + "--app-id=alloy-menu",
299 + "-e",
300 + "/usr/bin/alloy-menu",
301 + ]))
302 + .expect("the launcher's own command line must work");
303 +
304 + assert_eq!(cli.app_id.as_deref(), Some("alloy-menu"));
305 + assert_eq!(cli.exec_argv, Some(argv(&["/usr/bin/alloy-menu"])));
306 + }
307 +
308 + #[test]
309 + fn a_value_can_be_attached_with_equals_or_separated_by_a_space() {
310 + let attached = parse_args(cmdline(&["--theme=akari-night"])).expect("equals form");
311 + let separated = parse_args(cmdline(&["--theme", "akari-night"])).expect("space form");
312 +
313 + assert_eq!(attached.theme.as_deref(), Some("akari-night"));
314 + assert_eq!(attached, separated);
315 + }
316 +
317 + // An equals sign in a value is the value's business. Only the first one
318 + // separates, so a runner command or a path keeps its own.
319 + #[test]
320 + fn only_the_first_equals_separates() {
321 + let cli = parse_args(cmdline(&["--exec=echo a=b"])).expect("equals in a value");
322 +
323 + assert_eq!(cli.exec_cmd.as_deref(), Some("echo a=b"));
324 + }
325 +
326 + #[test]
327 + fn a_flag_that_takes_no_value_refuses_one() {
328 + let Err(CliExit::Reject(message)) = parse_args(cmdline(&["--help=please"])) else {
329 + panic!("--help does not take a value");
330 + };
331 + assert!(message.contains("takes no value"), "{message}");
332 + }
333 +
334 + #[test]
335 + fn dash_e_with_nothing_after_it_is_a_shell_not_an_error() {
336 + // A launcher that built a command line and found no command. Opening a
337 + // shell is more useful than refusing, and it is what the old scan did.
338 + let cli = parse_args(cmdline(&["-e"])).expect("-e alone is not a refusal");
339 + assert_eq!(cli.exec_argv, None);
340 + }
341 +
342 + #[test]
343 + fn flags_before_dash_e_are_still_ours() {
344 + let cli = parse_args(cmdline(&["--theme", "akari-night", "-e", "btop"]))
345 + .expect("both halves parse");
346 + assert_eq!(cli.theme.as_deref(), Some("akari-night"));
347 + assert_eq!(cli.exec_argv, Some(argv(&["btop"])));
348 + }
349 + }
@@ -1,0 +1,385 @@
1 + //! Painting a frame: the swapchain dance, the per-cell geometry the renderer
2 + //! is fed, and the two colour conversions between the theme and wgpu.
3 +
4 + use crate::theme::Palette;
5 + use crate::{App, CURSOR_ALPHA_DIM, CURSOR_ALPHA_ON, PAD_X, PAD_Y, SELECTION_ALPHA};
6 + use shop_grid::{Color as GridColor, CursorShape};
7 + use shop_render::{BgFill, CellMetrics, CellText};
8 + use tracing::warn;
9 +
10 + /// What the grid should answer about the terminal, at the current scale.
11 + ///
12 + /// Cell size is physical pixels, so it moves with the output scale and this
13 + /// has to be recomputed whenever that changes. The colours come from the
14 + /// theme, which is the only thing that knows them: a program asking OSC 11
15 + /// whether it is on a light or a dark terminal gets the wrong answer from
16 + /// anything else.
17 + pub(crate) fn identity(palette: &Palette, scale: u32, cell: CellMetrics) -> shop_grid::Identity {
18 + let s = scale.max(1) as f32;
19 + shop_grid::Identity {
20 + name: "shop".into(),
21 + version: env!("CARGO_PKG_VERSION").into(),
22 + cell_px: (
23 + (cell.advance * s).round() as u16,
24 + (cell.height * s).round() as u16,
25 + ),
26 + fg: srgb_bytes(palette.fg),
27 + bg: srgb_bytes(palette.bg),
28 + }
29 + }
30 +
31 + /// A palette colour back as the three bytes the theme file spelled it with.
32 + fn srgb_bytes([r, g, b, _]: [f32; 4]) -> [u8; 3] {
33 + [
34 + (r * 255.0).round() as u8,
35 + (g * 255.0).round() as u8,
36 + (b * 255.0).round() as u8,
37 + ]
38 + }
39 +
40 + pub(crate) fn grid_cols(px_w: u32, cell: CellMetrics) -> u16 {
41 + let usable = (px_w as f32 - 2.0 * PAD_X).max(cell.advance);
42 + (usable / cell.advance) as u16
43 + }
44 +
45 + pub(crate) fn grid_rows(px_h: u32, cell: CellMetrics) -> u16 {
46 + let usable = (px_h as f32 - 2.0 * PAD_Y).max(cell.height);
47 + (usable / cell.height) as u16
48 + }
49 +
50 + /// Render one frame. All content changes (PTY reads, resize, focus toggle,
51 + /// activity-light phase) go through this — never called directly except by
52 + /// the coalescing paths (main-loop-after-events and the frame callback).
53 + ///
54 + /// The frame callback that gates the next render is requested inside `draw`,
55 + /// on the commit that carries it, and `awaiting_frame` is set here only when
56 + /// that commit actually happened. The two have to agree: a callback counted as
57 + /// in flight but never sent is never answered, and since `awaiting_frame`
58 + /// blocks every later render, one failed frame would leave the window blank
59 + /// and unresponsive for the life of the process. A frame we could not present
60 + /// leaves `dirty` set instead, so the next event retries it.
61 + pub(crate) fn render_now(app: &mut App) {
62 + match draw(app) {
63 + Ok(()) => {
64 + app.awaiting_frame = true;
65 + app.dirty = false;
66 + }
67 + Err(e) => warn!("render: {e:?}"),
68 + }
69 + }
70 +
71 + /// The swapchain image to draw into, reconfiguring once if the surface went
72 + /// stale under us.
73 + ///
74 + /// `Outdated` and `Lost` mean the swapchain no longer matches the surface,
75 + /// which a reconfigure fixes; a compositor that resizes us between our own
76 + /// configure and this acquire produces one on an ordinary frame. `Timeout`
77 + /// means the compositor is holding every buffer and has none for us yet, so
78 + /// there is nothing to fix and the frame is skipped.
79 + fn acquire(app: &mut App) -> anyhow::Result<wgpu::SurfaceTexture> {
80 + use wgpu::CurrentSurfaceTexture::{Lost, Outdated, Suboptimal, Success};
81 + match app.surface.get_current_texture() {
82 + Success(t) | Suboptimal(t) => Ok(t),
83 + Outdated | Lost => {
84 + app.surface.configure(&app.device, &app.surface_config);
85 + match app.surface.get_current_texture() {
86 + Success(t) | Suboptimal(t) => Ok(t),
87 + other => anyhow::bail!("surface acquire after reconfigure: {other:?}"),
88 + }
89 + }
90 + other => anyhow::bail!("surface acquire: {other:?}"),
91 + }
92 + }
93 +
94 + // Short names are the local idiom for scale and cell geometry in the hot
95 + // path; longer ones bury the arithmetic.
96 + #[allow(clippy::many_single_char_names)]
97 + fn draw(app: &mut App) -> anyhow::Result<()> {
98 + let frame = acquire(app)?;
99 + let view = frame
100 + .texture
101 + .create_view(&wgpu::TextureViewDescriptor::default());
102 + let mut encoder = app
103 + .device
104 + .create_command_encoder(&wgpu::CommandEncoderDescriptor {
105 + label: Some("shop.frame"),
106 + });
107 + {
108 + let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
109 + label: Some("shop.clear"),
110 + color_attachments: &[Some(wgpu::RenderPassColorAttachment {
111 + view: &view,
112 + resolve_target: None,
113 + ops: wgpu::Operations {
114 + load: wgpu::LoadOp::Clear(clear_color(app.palette.bg)),
115 + store: wgpu::StoreOp::Store,
116 + },
117 + depth_slice: None,
118 + })],
119 + ..Default::default()
120 + });
121 + }
122 + // Damage flow: apply the grid's pending changes to the per-row cache,
123 + // rebuild only dirty rows, then draw from cache.
124 + let s = app.scale as f32;
125 + let cell_w_px = app.cell.advance * s;
126 + let cell_h_px = app.cell.height * s;
127 + let pad_x_px = PAD_X * s;
128 + let pad_y_px = PAD_Y * s;
129 + let damage = app.grid.take_damage();
130 + app.apply_damage_to_selection(&damage);
131 + app.text.ensure_rows(app.grid.rows());
132 + if damage.screen_swapped || damage.resized || damage.view_moved {
133 + app.text.clear_rows();
134 + }
135 + if damage.scroll != 0 {
136 + app.text.scroll(damage.scroll);
137 + }
138 + for &row in &damage.dirty_rows {
139 + app.text.update_row(
140 + &app.queue,
141 + row,
142 + app.grid
143 + .row(row)
144 + .iter()
145 + .enumerate()
146 + .filter_map(|(col, cell)| {
147 + let c = cell.c();
148 + let marks = app.grid.marks(cell);
149 + if (c == ' ' || c == '\0') && marks.is_empty() {
150 + return None;
151 + }
152 + let mut fg = resolve_color(cell.fg(), app.palette.fg, &app.palette);
153 + let mut bg = resolve_color(cell.bg(), app.palette.bg, &app.palette);
154 + if cell.reverse() {
155 + std::mem::swap(&mut fg, &mut bg);
156 + }
157 + let _ = bg;
158 + // Almost every cell is one character and takes the charmap
159 + // path; only a cell carrying marks is worth shaping.
160 + let text = if marks.is_empty() {
161 + CellText::Char(c)
162 + } else {
163 + let mut s = String::with_capacity(1 + marks.len());
164 + s.push(c);
165 + s.extend(marks);
166 + CellText::Cluster(s)
167 + };
168 + Some((col as u16, text, fg))
169 + }),
170 + );
171 + }
172 +
173 + // Fills (bg + underline + cursor) are rebuilt per-frame — cheap scan
174 + // dominated by the fast-reject for cells with default bg + no reverse
175 + // + no underline (~99% of cells in typical output).
176 + let mut fills: Vec<BgFill> = Vec::new();
177 + let cursor = app.grid.cursor();
178 + let cursor_shape = app.grid.cursor_shape();
179 + for r in 0..app.grid.rows() {
180 + let y = pad_y_px + r as f32 * cell_h_px;
181 + for (col, cell) in app.grid.row(r).iter().enumerate() {
182 + let has_bg = cell.has_bg();
183 + let underline = cell.underline();
184 + if !has_bg && !underline {
185 + continue;
186 + }
187 + let x = pad_x_px + col as f32 * cell_w_px;
188 + let mut fg = resolve_color(cell.fg(), app.palette.fg, &app.palette);
189 + if has_bg {
190 + let mut bg = resolve_color(cell.bg(), app.palette.bg, &app.palette);
191 + if cell.reverse() {
192 + std::mem::swap(&mut fg, &mut bg);
193 + }
194 + fills.push(BgFill {
195 + x,
196 + y,
197 + w: cell_w_px,
198 + h: cell_h_px,
199 + color: bg,
200 + });
201 + }
202 + if underline {
203 + fills.push(BgFill {
204 + x,
205 + y: y + cell_h_px - 2.0 * s,
206 + w: cell_w_px,
207 + h: 1.5 * s,
208 + color: fg,
209 + });
210 + }
211 + }
212 + }
213 +
214 + // Selection sits above the cells' own backgrounds and below the glyphs —
215 + // one quad per row, not one per cell — so the text under it keeps the
216 + // colour the program asked for and reads through the wash.
217 + if let Some(sel) = &app.selection {
218 + let span = app.grid.selection_span(sel);
219 + let [r, g, b, _] = app.palette.selection;
220 + let color = [r, g, b, SELECTION_ALPHA];
221 + for row in span.start.row..=span.end.row {
222 + let Some((lo, hi)) = span.cols_on(row, app.grid.cols()) else {
223 + continue;
224 + };
225 + fills.push(BgFill {
226 + x: pad_x_px + lo as f32 * cell_w_px,
227 + y: pad_y_px + row as f32 * cell_h_px,
228 + w: f32::from(hi - lo + 1) * cell_w_px,
229 + h: cell_h_px,
230 + color,
231 + });
232 + }
233 + }
234 +
235 + if let Some(cursor_row) = app.grid.cursor_view_row()
236 + && cursor.visible
237 + {
238 + let cx = pad_x_px + cursor.col as f32 * cell_w_px;
239 + let cy = pad_y_px + cursor_row as f32 * cell_h_px;
240 + let [r, g, b, _] = app.palette.cursor;
241 + let alpha = if app.cursor_phase {
242 + CURSOR_ALPHA_ON
243 + } else {
244 + CURSOR_ALPHA_DIM
245 + };
246 + let base = [r, g, b, alpha];
247 + // Dim to a hollow-looking ghost when the window doesn't have focus.
248 + let color = if app.focused {
249 + base
250 + } else {
251 + [base[0], base[1], base[2], base[3] * 0.35]
252 + };
253 + // A block or underline cursor covers the character it is on, which is
254 + // two columns wide when that character is.
255 + let cursor_w_px = f32::from(app.grid.cursor_cols()) * cell_w_px;
256 + let (w, h, ox, oy) = match cursor_shape {
257 + CursorShape::Block => (cursor_w_px, cell_h_px, 0.0, 0.0),
258 + CursorShape::Underline => (cursor_w_px, 2.0 * s, 0.0, cell_h_px - 2.0 * s),
259 + CursorShape::Bar => (2.0 * s, cell_h_px, 0.0, 0.0),
260 + };
261 + fills.push(BgFill {
262 + x: cx + ox,
263 + y: cy + oy,
264 + w,
265 + h,
266 + color,
267 + });
268 + }
269 +
270 + app.text.draw_cached(
271 + &app.device,
272 + &app.queue,
273 + &view,
274 + &mut encoder,
275 + &fills,
276 + pad_x_px,
277 + pad_y_px,
278 + cell_w_px,
279 + cell_h_px,
280 + );
281 + // Draw any live kitty-graphics image on top of the text layer.
282 + if let Some(placement) = app.image_placement {
283 + app.images.draw(
284 + &app.queue,
285 + &view,
286 + &mut encoder,
287 + std::slice::from_ref(&placement),
288 + );
289 + }
290 + app.queue.submit(std::iter::once(encoder.finish()));
291 + // Requested before presenting so it rides the commit `present` issues.
292 + // Both go down the same connection in the order they were made, so this
293 + // is the last point at which the request still lands on this frame.
294 + app.chrome.surface.wl_surface().frame(&app.qh, ());
295 + app.queue.present(frame);
296 + Ok(())
297 + }
298 +
299 + /// The theme's page colour, as wgpu wants the clear value.
300 + ///
301 + /// Every cell's background is painted over this, so it only shows in the
302 + /// padding around the grid. Which is exactly why it has to be the theme's page
303 + /// colour and not black: a light theme with a black frame around it reads as a
304 + /// rendering bug.
305 + fn clear_color([r, g, b, a]: [f32; 4]) -> wgpu::Color {
306 + wgpu::Color {
307 + r: f64::from(r),
308 + g: f64::from(g),
309 + b: f64::from(b),
310 + a: f64::from(a),
311 + }
312 + }
313 +
314 + /// One cell's colour, as the grid asked for it.
315 + ///
316 + /// `Default` is the only case the palette does not answer directly: it means
317 + /// the program said nothing, so the terminal's own default text or background
318 + /// colour applies, and which of the two depends on whether this is a
319 + /// foreground or a background lookup.
320 + fn resolve_color(c: GridColor, default: [f32; 4], palette: &Palette) -> [f32; 4] {
321 + match c {
322 + GridColor::Default => default,
323 + GridColor::Named(i) => palette.ansi(i),
324 + GridColor::Indexed(i) => palette.indexed(i),
325 + GridColor::Rgb(r, g, b) => [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0],
326 + }
327 + }
328 +
329 + #[cfg(test)]
330 + mod tests {
331 + use super::*;
332 + use crate::{FONT_BYTES, FONT_PX};
333 +
334 + /// The real cell, measured off the real bundled face.
335 + ///
336 + /// Not a fixture. Hit-testing against numbers a test made up would pass
337 + /// while the terminal put the pointer in the wrong cell, which is the
338 + /// class of bug this whole change is about.
339 + fn test_cell() -> CellMetrics {
340 + CellMetrics::measure(FONT_BYTES, FONT_PX).expect("the bundled face measures")
341 + }
342 +
343 + // The regression guard the two constants never had. They were 8.0 x 17.0
344 + // and derived from no face at all, so every column carried a pixel of dead
345 + // space and every row lost half of one.
346 + //
347 + // Asserted as a relationship rather than as two numbers: the numbers are
348 + // the bundled face's, and the bundled face has already changed once.
349 + #[test]
350 + fn the_cell_is_the_bundled_faces_own_and_not_a_number_someone_typed() {
351 + let cell = test_cell();
352 + assert!(
353 + (cell.advance - cell.exact_advance.ceil()).abs() < f32::EPSILON,
354 + "{cell:?}"
355 + );
356 + assert!(
357 + (cell.height - cell.exact_height.ceil()).abs() < f32::EPSILON,
358 + "{cell:?}"
359 + );
360 + // Under a pixel of rounding in both axes, which is what the furniture
361 + // snap absorbs. More than that would mean the cell and the face have
362 + // come apart rather than been rounded.
363 + let (dx, dy) = cell.rounding_error();
364 + assert!(dx < 1.0 && dy < 1.0, "{cell:?} rounds by {dx} x {dy}");
365 + }
366 +
367 + // Cells tile: what the grid steps by is what a glyph fills. Asserted
368 + // through the same two functions the resize path uses rather than by
369 + // arithmetic, so a change to either is caught.
370 + #[test]
371 + fn a_window_divides_into_whole_cells_with_only_the_padding_left_over() {
372 + let cell = test_cell();
373 + let width = 960;
374 + let cols = grid_cols(width, cell);
375 + let used = f32::from(cols) * cell.advance + 2.0 * PAD_X;
376 + assert!(
377 + used <= width as f32,
378 + "{cols} columns need {used}px of {width}"
379 + );
380 + assert!(
381 + used > width as f32 - cell.advance,
382 + "another column would have fitted"
383 + );
384 + }
385 + }
@@ -1,0 +1,207 @@
1 + //! The kitty graphics protocol, and the sink that separates its payloads from
2 + //! the terminal byte stream.
3 + //!
4 + //! Image payloads arrive inside APC strings interleaved with ordinary output,
5 + //! so the parse pass collects the bodies and the event loop hands them here
6 + //! afterwards rather than acting on them mid-parse.
7 +
8 + use crate::{App, PAD_X, PAD_Y};
9 + use kittygfx as kgp;
10 + use shop_grid::Grid;
11 + use shop_render::ImagePlacement;
12 + use tracing::warn;
13 +
14 + /// Composite Perform: forwards the mainline callbacks to `Grid` and pushes
15 + /// APC bodies onto a separate queue. shop-vt exposes an `apc_dispatch` that
16 + /// `vte` off crates.io hides, which is what makes this crate hookup possible
17 + /// without a byte-stream pre-scanner.
18 + pub(crate) struct ParseSink<'a> {
19 + pub(crate) grid: &'a mut Grid,
20 + pub(crate) apc: &'a mut Vec<Vec<u8>>,
21 + }
22 +
23 + impl shop_vt::Perform for ParseSink<'_> {
24 + fn print(&mut self, c: char) {
25 + <Grid as shop_vt::Perform>::print(self.grid, c);
26 + }
27 + fn execute(&mut self, byte: u8) {
28 + <Grid as shop_vt::Perform>::execute(self.grid, byte);
29 + }
30 + fn csi_dispatch(
31 + &mut self,
32 + params: &shop_vt::Params,
33 + intermediates: &[u8],
34 + ignore: bool,
35 + action: char,
36 + ) {
37 + <Grid as shop_vt::Perform>::csi_dispatch(self.grid, params, intermediates, ignore, action);
38 + }
39 + fn esc_dispatch(&mut self, intermediates: &[u8], ignore: bool, byte: u8) {
40 + <Grid as shop_vt::Perform>::esc_dispatch(self.grid, intermediates, ignore, byte);
41 + }
42 + fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) {
43 + <Grid as shop_vt::Perform>::osc_dispatch(self.grid, params, bell_terminated);
44 + }
45 + fn apc_dispatch(&mut self, data: &[u8]) {
46 + self.apc.push(data.to_vec());
47 + }
48 + }
49 +
50 + // Image dimensions are w/h throughout the kitty protocol; spelling them out
51 + // here would not match the spec being implemented.
52 + #[allow(clippy::many_single_char_names)]
53 + pub(crate) fn handle_kitty(app: &mut App, cmd: kgp::Command) {
54 + match cmd {
55 + kgp::Command::Transmit { control, payload } => {
56 + let Some(format) = control.format else {
57 + warn!("kitty: transmit missing format");
58 + return;
59 + };
60 + let rgba = match format {
61 + kgp::Format::Png => {
62 + match image::load_from_memory_with_format(&payload, image::ImageFormat::Png) {
63 + Ok(img) => {
64 + let rgba = img.to_rgba8();
65 + let (w, h) = (rgba.width(), rgba.height());
66 + Some((rgba.into_raw(), w, h))
67 + }
68 + Err(e) => {
69 + warn!("kitty: png decode: {e}");
70 + None
71 + }
72 + }
73 + }
74 + kgp::Format::Rgba => {
75 + let (w, h) = (
76 + control.width_px.unwrap_or(0),
77 + control.height_px.unwrap_or(0),
78 + );
79 + if w == 0 || h == 0 {
80 + warn!("kitty: rgba transmit missing s= / v=");
81 + None
82 + } else {
83 + Some((payload, w, h))
84 + }
85 + }
86 + kgp::Format::Rgb => {
87 + let (w, h) = (
88 + control.width_px.unwrap_or(0),
89 + control.height_px.unwrap_or(0),
90 + );
91 + if w == 0 || h == 0 {
92 + warn!("kitty: rgb transmit missing s= / v=");
93 + None
94 + } else {
95 + let mut rgba = Vec::with_capacity((w * h * 4) as usize);
96 + for chunk in payload.chunks_exact(3) {
97 + rgba.extend_from_slice(chunk);
98 + rgba.push(0xff);
99 + }
100 + Some((rgba, w, h))
101 + }
102 + }
103 + };
104 + let Some((rgba, w, h)) = rgba else {
105 + return;
106 + };
107 + let image_id = control.id.unwrap_or_else(|| {
108 + let id = app.next_anon_image_id;
109 + app.next_anon_image_id = app.next_anon_image_id.wrapping_add(1);
110 + id
111 + });
112 + app.images
113 + .set_image(&app.device, &app.queue, image_id, &rgba, w, h);
114 +
115 + if control.action == 'T' {
116 + let s = app.scale as f32;
117 + let cursor = app.grid.cursor();
118 + let x = (PAD_X + cursor.col as f32 * app.cell.advance) * s;
119 + let y = (PAD_Y + cursor.row as f32 * app.cell.height) * s;
120 + // Prefer the cell-sized placement (`c=`, `r=`) if given; fall
121 + // back to the source pixel dims scaled up. yazi always sends
122 + // `c` and `r`.
123 + let placement_w = control
124 + .cell_cols
125 + .map_or(w as f32, |c| c as f32 * app.cell.advance * s);
126 + let placement_h = control
127 + .cell_rows
128 + .map_or(h as f32, |r| r as f32 * app.cell.height * s);
129 + app.image_placement = Some(ImagePlacement {
130 + image_id,
131 + x,
132 + y,
133 + w: placement_w,
134 + h: placement_h,
135 + });
136 + }
137 + }
138 + kgp::Command::Delete { .. } => {
139 + app.images.drop_all();
140 + app.image_placement = None;
141 + }
142 + // "Can you draw this?" Answered rather than ignored, because that is
143 + // how a terminal nothing has heard of gets its graphics support
144 + // noticed: clients match a list of terminal names first and fall back
145 + // to asking when the name means nothing to them. yazi is the one that
146 + // matters here, and it asks precisely because shop is in no list.
147 + kgp::Command::Query { control } => {
148 + let reply = kgp::query_response(&control);
149 + if let Err(e) = app.pty.write(&reply) {
150 + warn!("pty write (kitty query): {e}");
151 + }
152 + }
153 + // Multi-image placement + animation are parsed but not yet rendered
154 + // (shop tasks 509fd8cb, e61fb2a1). Dropping them here matches the
155 + // current single-image renderer.
156 + kgp::Command::Place { .. }
157 + | kgp::Command::FrameAppend { .. }
158 + | kgp::Command::FrameCompose { .. } => {}
159 + }
160 + }
161 +
162 + #[cfg(test)]
163 + mod tests {
164 + use super::*;
165 + use shop_vt::Perform;
166 +
167 + #[test]
168 + fn apc_bodies_are_collected_and_left_out_of_the_grid() {
169 + let mut grid = Grid::new(10, 4);
170 + let mut apc = Vec::new();
171 + {
172 + let mut sink = ParseSink {
173 + grid: &mut grid,
174 + apc: &mut apc,
175 + };
176 + sink.apc_dispatch(b"Gf=100,a=T");
177 + sink.apc_dispatch(b"Ga=d");
178 + }
179 + assert_eq!(apc, vec![b"Gf=100,a=T".to_vec(), b"Ga=d".to_vec()]);
180 + // An image payload that reached the grid would be drawn as text.
181 + assert_eq!(grid.cursor().col, 0);
182 + }
183 +
184 + #[test]
185 + fn the_mainline_callbacks_go_through_to_the_grid() {
186 + let mut grid = Grid::new(10, 4);
187 + let mut apc = Vec::new();
188 + {
189 + let mut sink = ParseSink {
190 + grid: &mut grid,
191 + apc: &mut apc,
192 + };
193 + sink.print('h');
194 + sink.print('i');
195 + }
196 + assert_eq!(grid.cursor().col, 2);
197 + {
198 + let mut sink = ParseSink {
199 + grid: &mut grid,
200 + apc: &mut apc,
201 + };
202 + sink.execute(b'\r');
203 + }
204 + assert_eq!(grid.cursor().col, 0);
205 + assert!(apc.is_empty());
206 + }
207 + }
@@ -1,0 +1,328 @@
1 + //! The keyboard: the seat and keyboard handlers, shop's own bindings, and the
2 + //! encode path that turns everything else into bytes on the PTY.
3 +
4 + use crate::App;
5 + use crate::emit;
6 + use crate::keys::Action;
7 + use shop_wayland::{Capability, Connection, QueueHandle, SeatHandler, SeatState};
8 + use smithay_client_toolkit::seat::keyboard::{
9 + KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo,
10 + };
11 + use smithay_client_toolkit::seat::pointer::ThemeSpec;
12 + use tracing::warn;
13 + use wayland_client::protocol::wl_keyboard;
14 + use wayland_client::protocol::{wl_seat, wl_surface};
15 +
16 + impl SeatHandler for App {
17 + fn seat_state(&mut self) -> &mut SeatState {
18 + &mut self.chrome.seat
19 + }
20 + fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
21 + // Both selections are per-seat, so the devices cannot exist before
22 + // one does. First seat wins: shop is one window with one focus, and a
23 + // second seat's clipboard is not a thing it has a way to show.
24 + if self.data_device.is_none()
25 + && let Some(mgr) = &self.data_device_manager
26 + {
27 + self.data_device = Some(mgr.get_data_device(qh, &seat));
28 + }
29 + if self.primary_device.is_none()
30 + && let Some(mgr) = &self.primary_manager
31 + {
32 + self.primary_device = Some(mgr.get_selection_device(qh, &seat));
33 + }
34 + }
35 + fn new_capability(
36 + &mut self,
37 + _: &Connection,
38 + qh: &QueueHandle<Self>,
39 + seat: wl_seat::WlSeat,
40 + capability: Capability,
41 + ) {
42 + if capability == Capability::Keyboard && self.keyboard.is_none() {
43 + // With repeat, which means handing SCTK the loop handle: it arms a
44 + // calloop timer per press at the compositor's advertised rate.
45 + // The plain `get_keyboard` compiles and runs and simply never
46 + // repeats, so holding a key types one character — which looks like
47 + // a stuck keyboard rather than a missing argument.
48 + let handle = self.loop_handle.clone();
49 + let repeat = self.chrome.seat.get_keyboard_with_repeat(
50 + qh,
51 + &seat,
52 + None,
53 + handle,
54 + Box::new(|app: &mut App, _kb, event| app.handle_key(&event)),
55 + );
56 + match repeat {
57 + Ok(kb) => self.keyboard = Some(kb),
58 + Err(e) => warn!("get_keyboard_with_repeat: {e}"),
59 + }
60 + }
61 + if capability == Capability::Pointer
62 + && self.pointer.is_none()
63 + && let Some(shm) = &self.shm
64 + {
65 + // The surface is the cursor's, not the window's: the XCursor
66 + // fallback attaches its image to it. `wp_cursor_shape_v1` leaves
67 + // it unused, which is the common case and costs one wl_surface.
68 + //
69 + // `ThemeSpec::System` reads XCURSOR_THEME and XCURSOR_SIZE, so a
70 + // user who themes their pointer keeps their theme rather than
71 + // getting shop's idea of one.
72 + let cursor_surface = self.chrome.compositor.create_surface(qh);
73 + // `S` is the surface's user data, `()` because `create_surface`
74 + // above makes a plain one; inference has nothing else to go on.
75 + let themed = self.chrome.seat.get_pointer_with_theme::<Self, ()>(
76 + qh,
77 + &seat,
78 + shm.wl_shm(),
79 + cursor_surface,
80 + ThemeSpec::System,
81 + );
82 + match themed {
83 + Ok(ptr) => self.pointer = Some(ptr),
84 + Err(e) => warn!("get_pointer_with_theme: {e}"),
85 + }
86 + }
87 + }
88 + fn remove_capability(
89 + &mut self,
90 + _: &Connection,
91 + _: &QueueHandle<Self>,
92 + _: wl_seat::WlSeat,
93 + capability: Capability,
94 + ) {
95 + if capability == Capability::Keyboard
96 + && let Some(kb) = self.keyboard.take()
97 + {
98 + kb.release();
99 + }
100 + if capability == Capability::Pointer
101 + && let Some(ptr) = self.pointer.take()
102 + {
103 + // ThemedPointer's Drop releases the wl_pointer and destroys the
104 + // shape device and the cursor surface, so taking it is the whole
105 + // teardown; an explicit release here would be a double release.
106 + drop(ptr);
107 + // A pointer that goes away mid-drag leaves no way to finish the
108 + // gesture, so end it where it stands rather than latching.
109 + self.dragging = false;
110 + }
111 + }
112 + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
113 + }
114 +
115 + impl KeyboardHandler for App {
116 + fn enter(
117 + &mut self,
118 + _: &Connection,
119 + _: &QueueHandle<Self>,
120 + _: &wl_keyboard::WlKeyboard,
121 + _: &wl_surface::WlSurface,
122 + _: u32,
123 + _: &[u32],
124 + _: &[Keysym],
125 + ) {
126 + self.focused = true;
127 + self.dirty = true;
128 + }
129 + fn leave(
130 + &mut self,
131 + _: &Connection,
132 + _: &QueueHandle<Self>,
133 + _: &wl_keyboard::WlKeyboard,
134 + _: &wl_surface::WlSurface,
135 + _: u32,
136 + ) {
137 + self.focused = false;
138 + self.dirty = true;
139 + }
140 + fn press_key(
141 + &mut self,
142 + _: &Connection,
143 + _: &QueueHandle<Self>,
144 + _: &wl_keyboard::WlKeyboard,
145 + serial: u32,
146 + event: KeyEvent,
147 + ) {
148 + self.last_serial = serial;
149 + self.handle_key(&event);
150 + }
151 + fn repeat_key(
152 + &mut self,
153 + _: &Connection,
154 + _: &QueueHandle<Self>,
155 + _: &wl_keyboard::WlKeyboard,
156 + _: u32,
157 + _: KeyEvent,
158 + ) {
159 + // Deliberately empty. Repeats come from the calloop timer SCTK arms in
160 + // `get_keyboard_with_repeat`, and that timer is armed on every press
161 + // whether or not the compositor also sends its own repeat events. A
162 + // wl_keyboard v10 compositor sends both, so doing the work here as
163 + // well would type every held key twice.
164 + }
165 + fn release_key(
166 + &mut self,
167 + _: &Connection,
168 + _: &QueueHandle<Self>,
169 + _: &wl_keyboard::WlKeyboard,
170 + _: u32,
171 + _: KeyEvent,
172 + ) {
173 + }
174 + fn update_modifiers(
175 + &mut self,
176 + _: &Connection,
177 + _: &QueueHandle<Self>,
178 + _: &wl_keyboard::WlKeyboard,
179 + _: u32,
180 + modifiers: Modifiers,
181 + _: RawModifiers,
182 + _: u32,
183 + ) {
184 + self.modifiers = modifiers;
185 + }
186 + fn update_repeat_info(
187 + &mut self,
188 + _: &Connection,
189 + _: &QueueHandle<Self>,
190 + _: &wl_keyboard::WlKeyboard,
191 + _: RepeatInfo,
192 + ) {
193 + }
194 + }
195 +
196 + /// SCTK's modifier set as shop-xkb's.
197 + ///
198 + /// Two structurally identical types, kept apart on purpose: shop-xkb does not
199 + /// depend on the Wayland stack, and this three-line conversion is the whole
200 + /// price of that.
201 + fn xkb_mods(m: Modifiers) -> shop_xkb::Mods {
202 + shop_xkb::Mods {
203 + shift: m.shift,
204 + alt: m.alt,
205 + ctrl: m.ctrl,
206 + logo: m.logo,
207 + }
208 + }
209 +
210 + impl App {
211 + /// Shop's own key bindings, which never reach the shell.
212 + ///
213 + /// Which chord runs which action is the user's, not shop's: a key shop
214 + /// consumes never reaches the program inside it, so every one of these is
215 + /// rebindable and every one can be given back with `none`. See
216 + /// [`crate::keys`].
217 + ///
218 + /// Returns true when the key was consumed.
219 + fn handle_binding(&mut self, event: &KeyEvent) -> bool {
220 + let Some(action) = self.bindings.action(xkb_mods(self.modifiers), event.keysym) else {
221 + return false;
222 + };
223 + match action {
224 + // A page is one screen less a row, so a line of context carries
225 + // over and the reader can stitch the two screens together.
226 + Action::ScrollPageUp => {
227 + let page = self.grid.rows().saturating_sub(1).max(1);
228 + self.scroll_view(|g| g.scroll_view_up(page));
229 + }
230 + Action::ScrollPageDown => {
231 + let page = self.grid.rows().saturating_sub(1).max(1);
232 + self.scroll_view(|g| g.scroll_view_down(page));
233 + }
234 + Action::Copy => {
235 + if let Some(text) = self.selection_text() {
236 + self.set_clipboard(text);
237 + }
238 + // Consumed even with nothing selected: falling through to the
239 + // shell would send an interrupt to whatever is running, which
240 + // is not what the user asked for.
241 + }
242 + Action::Paste => self.paste_clipboard(),
243 + // Addressed absolutely: row 0 is the oldest row still in
244 + // scrollback, so a range does not shift under the user when
245 + // output arrives between binding the key and reading it.
246 + Action::EmitBuffer => {
247 + let text = self.grid.text_range(0, self.grid.abs_rows());
248 + self.emit(&text);
249 + }
250 + Action::EmitScreen => {
251 + let top = self.grid.abs_row_of_view(0);
252 + let text = self.grid.text_range(top, top + self.grid.rows() as usize);
253 + self.emit(&text);
254 + }
255 + Action::EmitSelection => {
256 + let text = self.selection_text().unwrap_or_default();
257 + self.emit(&text);
258 + }
259 + }
260 + true
261 + }
262 +
263 + /// Hand a captured region to the configured runner.
264 + fn emit(&mut self, text: &str) {
265 + self.emit_seq += 1;
266 + emit::emit(&self.runner, text, self.emit_seq);
267 + }
268 +
269 + /// Turn a key press into bytes on the PTY.
270 + ///
271 + /// Shop's own bindings get first refusal; everything else goes to
272 + /// [`shop_xkb::encode`], which needs the two DEC modes because the same
273 + /// keycap sends different bytes depending on what the program asked for.
274 + fn handle_key(&mut self, event: &KeyEvent) {
275 + if self.handle_binding(event) {
276 + return;
277 + }
278 + let modes = shop_xkb::Modes {
279 + cursor_keys_application: self.grid.cursor_keys_application(),
280 + keypad_application: self.grid.keypad_application(),
281 + };
282 + let bytes = shop_xkb::encode(
283 + event.keysym,
284 + event.utf8.as_deref().unwrap_or_default(),
285 + xkb_mods(self.modifiers),
286 + modes,
287 + );
288 + if bytes.is_empty() {
289 + return;
290 + }
291 + // Typing goes to a program whose output is at the bottom, so the
292 + // screen follows the keystrokes back down. Anything that reached here
293 + // produced bytes, which means it was input and not a shop binding.
294 + self.scroll_view(shop_grid::Grid::scroll_view_to_bottom);
295 + if let Err(e) = self.pty.write(&bytes) {
296 + warn!("pty write: {e}");
297 + }
298 + }
299 + }
300 +
301 + #[cfg(test)]
302 + mod tests {
303 + use super::*;
304 +
305 + /// The two modifier sets are structurally identical and deliberately kept
306 + /// apart, so this conversion is the only thing holding them in line.
307 + #[test]
308 + fn every_modifier_crosses_over_under_its_own_name() {
309 + let none = xkb_mods(Modifiers::default());
310 + assert!(!none.shift && !none.alt && !none.ctrl && !none.logo);
311 +
312 + let ctrl_shift = xkb_mods(Modifiers {
313 + ctrl: true,
314 + shift: true,
315 + ..Modifiers::default()
316 + });
317 + assert!(ctrl_shift.ctrl && ctrl_shift.shift);
318 + assert!(!ctrl_shift.alt && !ctrl_shift.logo);
319 +
320 + let alt_logo = xkb_mods(Modifiers {
321 + alt: true,
322 + logo: true,
323 + ..Modifiers::default()
324 + });
325 + assert!(alt_logo.alt && alt_logo.logo);
326 + assert!(!alt_logo.ctrl && !alt_logo.shift);
327 + }
328 + }
@@ -1,0 +1,612 @@
1 + //! The pointer: hit testing, selection, wheel turns, and mouse reporting.
2 + //!
3 + //! The local behaviour and the program's are two answers to the same event, and
4 + //! which one applies is decided per event by `App::report_mouse`.
5 +
6 + use crate::{App, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, MULTI_CLICK_MS, PAD_X, PAD_Y, WHEEL_LINES};
7 + use shop_grid::{
8 + Grid, MouseAction, MouseButton, MouseMods, MouseReport, MouseTracking, Point, Selection,
9 + SelectionMode,
10 + };
11 + use shop_render::CellMetrics;
12 + use shop_wayland::{Connection, QueueHandle};
13 + use smithay_client_toolkit::seat::keyboard::Keysym;
14 + use smithay_client_toolkit::seat::pointer::{
15 + CursorIcon, PointerEvent, PointerEventKind, PointerHandler,
16 + };
17 + use tracing::warn;
18 + use wayland_client::protocol::wl_pointer;
19 +
20 + /// The three buttons a terminal has a number for. Anything else on the mouse
21 + /// is not reportable, so it is left alone rather than folded into one of these.
22 + fn mouse_button(button: u32) -> Option<MouseButton> {
23 + match button {
24 + BTN_LEFT => Some(MouseButton::Left),
25 + BTN_MIDDLE => Some(MouseButton::Middle),
26 + BTN_RIGHT => Some(MouseButton::Right),
27 + _ => None,
28 + }
29 + }
30 +
31 + /// The cell under a surface-local position.
32 + ///
33 + /// Positions arrive in logical pixels, which is also what the padding and the
34 + /// cell are in, so scale does not enter into this. Clamped rather than
35 + /// optional: a pointer out in the padding is treated as the nearest cell,
36 + /// which is what makes dragging off the edge of the window select to the end
37 + /// of the line instead of stopping dead.
38 + fn cell_at((x, y): (f64, f64), cols: u16, rows: u16, cell: CellMetrics) -> Point {
39 + let col = ((x - f64::from(PAD_X)) / f64::from(cell.advance)).floor();
40 + let row = ((y - f64::from(PAD_Y)) / f64::from(cell.height)).floor();
41 + let to_index = |v: f64, count: u16| v.clamp(0.0, f64::from(count.saturating_sub(1))) as u16;
42 + Point::new(to_index(row, rows), to_index(col, cols))
43 + }
44 +
45 + /// Granularity counter for a press: 1 char, 2 word, 3 line.
46 + ///
47 + /// Climbs only for repeat presses in the same cell inside the double-click
48 + /// interval, and wraps, so a fourth click starts over at char granularity
49 + /// rather than sticking on whole lines.
50 + fn next_click_count(previous: Option<(u32, Point)>, count: u32, time: u32, at: Point) -> u32 {
51 + match previous {
52 + // wrapping_sub because the compositor's millisecond clock has an
53 + // arbitrary origin and is free to wrap; the difference stays right
54 + // across the wrap even though the operands don't.
55 + Some((last, cell)) if cell == at && time.wrapping_sub(last) < MULTI_CLICK_MS => {
56 + count % 3 + 1
57 + }
58 + _ => 1,
59 + }
60 + }
61 +
62 + impl PointerHandler for App {
63 + fn pointer_frame(
64 + &mut self,
65 + conn: &Connection,
66 + _: &QueueHandle<Self>,
67 + _: &wl_pointer::WlPointer,
68 + events: &[PointerEvent],
69 + ) {
70 + for event in events {
71 + // A drag that ends outside the window still delivers its release
72 + // to us, but anything addressed to another surface is not ours to
73 + // act on.
74 + if &event.surface != self.chrome.surface.wl_surface() {
75 + continue;
76 + }
77 + self.pointer_at = event.position;
78 + match event.kind {
79 + PointerEventKind::Enter { .. } => {
80 + // Per entry, not once at startup: the shape is attached to
81 + // an enter serial, and the compositor resets the pointer to
82 + // its own default every time it crosses back in.
83 + //
84 + // The whole window gets the I-beam, not only the cells. The
85 + // padding is dead space that `cell_at` already treats as
86 + // the nearest cell, so a shape change at its edge would
87 + // advertise a boundary that selection does not have.
88 + if let Some(ptr) = &self.pointer
89 + && let Err(e) = ptr.set_cursor(conn, CursorIcon::Text)
90 + {
91 + warn!("set_cursor: {e}");
92 + }
93 + self.drag_selection();
94 + }
95 + PointerEventKind::Motion { .. } => {
96 + // A held button makes this a drag; nothing held makes it
97 + // the hover that only the any-motion level asked for.
98 + let button = self.mouse_held.unwrap_or(MouseButton::None);
99 + if !self.report_mouse(button, MouseAction::Motion) {
100 + self.drag_selection();
101 + }
102 + }
103 + PointerEventKind::Press {
104 + button,
105 + time,
106 + serial,
107 + } if mouse_button(button).is_some() => {
108 + self.last_serial = serial;
109 + let named = mouse_button(button).expect("guarded above");
110 + if self.report_mouse(named, MouseAction::Press) {
111 + self.mouse_held = Some(named);
112 + } else if button == BTN_LEFT {
113 + self.begin_selection(time);
114 + } else if button == BTN_MIDDLE {
115 + self.paste_primary();
116 + }
117 + }
118 + PointerEventKind::Release { button, serial, .. }
119 + if mouse_button(button).is_some() =>
120 + {
121 + self.last_serial = serial;
122 + let named = mouse_button(button).expect("guarded above");
123 + // Cleared before the report, not after: a release ends the
124 + // hold whether or not the program wanted to hear about it,
125 + // and a stale hold would label every later hover a drag.
126 + if self.mouse_held == Some(named) {
127 + self.mouse_held = None;
128 + }
129 + if !self.report_mouse(named, MouseAction::Release) && button == BTN_LEFT {
130 + self.finish_selection();
131 + } else if button == BTN_LEFT {
132 + // A program can start tracking between a press and its
133 + // release, and then this release is the only end the
134 + // drag will ever get. Without it the pointer keeps
135 + // dragging a selection nobody can see.
136 + self.dragging = false;
137 + }
138 + }
139 + PointerEventKind::Axis { vertical, .. } => {
140 + // `discrete` is notches where the compositor reports them
141 + // and zero on a touchpad, where `absolute` carries a
142 + // continuous distance; take the notch count when there is
143 + // one and fall back to the sign of the distance.
144 + let notches = if vertical.discrete != 0 {
145 + vertical.discrete
146 + } else if vertical.absolute > 0.0 {
147 + 1
148 + } else if vertical.absolute < 0.0 {
149 + -1
150 + } else {
151 + 0
152 + };
153 + // A tracking program gets one report per notch and does
154 + // its own scrolling; the local wheel does not also run, or
155 + // the pager would move twice.
156 + let button = if notches < 0 {
157 + MouseButton::WheelUp
158 + } else {
159 + MouseButton::WheelDown
160 + };
161 + let mut taken = false;
162 + for _ in 0..notches.unsigned_abs() {
163 + taken = self.report_mouse(button, MouseAction::Press);
164 + }
165 + if !taken {
166 + self.apply_wheel(notches);
167 + }
168 + }
169 + PointerEventKind::Leave { .. } => {
170 + // Keep the drag alive: the pointer leaving the window
171 + // during a selection is normal, and the release will find
172 + // its way back to us.
173 + }
174 + _ => {}
175 + }
176 + }
177 + }
178 + }
179 +
180 + /// What a wheel turn amounts to, once the screen and the modes are known.
181 + ///
182 + /// Split out from the pointer handler so the decision is testable without a
183 + /// compositor: the arithmetic and the alt-screen rule are the parts worth
184 + /// getting right, and neither of them needs a window.
185 + #[derive(Debug, PartialEq, Eq)]
186 + enum WheelAction {
187 + /// Move the viewport back into history by this many rows.
188 + Up(u16),
189 + /// Move the viewport toward the live screen by this many rows.
190 + Down(u16),
191 + /// Write these bytes to the PTY.
192 + Keys(Vec<u8>),
193 + Nothing,
194 + }
195 +
196 + /// Decide what a wheel turn of `notches` does. Negative is up, toward older
197 + /// output.
198 + ///
199 + /// Off the alt screen the wheel moves the viewport through scrollback. On it
200 + /// there is no history to move through — the offset is pinned at zero — so the
201 + /// turn becomes cursor keys instead, which is what makes `less`, `man` and
202 + /// `git log` scroll. DECSET 1007 is the program's way of declining that.
203 + fn wheel_action(
204 + notches: i32,
205 + on_alt: bool,
206 + alternate_scroll: bool,
207 + modes: shop_xkb::Modes,
208 + ) -> WheelAction {
209 + if notches == 0 {
210 + return WheelAction::Nothing;
211 + }
212 + let lines = notches
213 + .unsigned_abs()
214 + .saturating_mul(u32::from(WHEEL_LINES))
215 + .min(u32::from(u16::MAX)) as u16;
216 + let up = notches < 0;
217 +
218 + if !on_alt {
219 + return if up {
220 + WheelAction::Up(lines)
221 + } else {
222 + WheelAction::Down(lines)
223 + };
224 + }
225 + if !alternate_scroll {
226 + return WheelAction::Nothing;
227 + }
228 + // One arrow per row the viewport would have moved, so a turn covers the
229 + // same distance whichever screen it lands on. `encode` is the only place
230 + // that knows whether DECCKM makes that `CSI B` or `SS3 B`.
231 + let keysym = if up { Keysym::Up } else { Keysym::Down };
232 + let one = shop_xkb::encode(keysym, "", shop_xkb::Mods::default(), modes);
233 + let mut bytes = Vec::with_capacity(one.len() * lines as usize);
234 + for _ in 0..lines {
235 + bytes.extend_from_slice(&one);
236 + }
237 + WheelAction::Keys(bytes)
238 + }
239 +
240 + impl App {
241 + fn cell_at(&self, pos: (f64, f64)) -> Point {
242 + let at = cell_at(pos, self.grid.cols(), self.grid.rows(), self.cell);
243 + // A wide character is one thing under two columns; a click on its right
244 + // half means the character, not the blank standing in for it.
245 + Point::new(at.row, self.grid.snap_col(at.row, at.col))
246 + }
247 +
248 + fn begin_selection(&mut self, time: u32) {
249 + let at = self.cell_at(self.pointer_at);
250 + self.click_count = next_click_count(self.last_click, self.click_count, time, at);
251 + self.last_click = Some((time, at));
252 + let mode = if self.modifiers.ctrl {
253 + SelectionMode::Block
254 + } else {
255 + match self.click_count {
256 + 1 => SelectionMode::Char,
257 + 2 => SelectionMode::Word,
258 + _ => SelectionMode::Line,
259 + }
260 + };
261 + self.selection = Some(Selection::new(mode, at));
262 + self.dragging = true;
263 + self.dirty = true;
264 + }
265 +
266 + fn drag_selection(&mut self) {
267 + if !self.dragging {
268 + return;
269 + }
270 + let at = self.cell_at(self.pointer_at);
271 + if let Some(sel) = &mut self.selection
272 + && sel.head != at
273 + {
274 + sel.drag_to(at);
275 + self.dirty = true;
276 + }
277 + }
278 +
279 + fn finish_selection(&mut self) {
280 + self.dragging = false;
281 + // A press and release in one cell is a click, not a selection. Drop it
282 + // so clicking to focus the window doesn't leave a lit cell behind.
283 + if self.selection.is_some_and(|sel| sel.is_empty()) {
284 + self.selection = None;
285 + self.dirty = true;
286 + return;
287 + }
288 + // Finishing a selection fills the primary selection and nothing else.
289 + // That is the X convention every other terminal still keeps, and it is
290 + // what makes select-then-middle-click work without ever touching what
291 + // the user last copied deliberately.
292 + if let Some(sel) = self.selection {
293 + let text = self.grid.selection_text(&sel);
294 + if !text.is_empty() {
295 + self.set_primary(text);
296 + }
297 + }
298 + }
299 +
300 + /// Run a viewport move and ask for a frame if it went anywhere.
301 + ///
302 + /// The redraw has to be explicit: moving the viewport changes no cell, so
303 + /// nothing else in the loop would notice that the screen is now wrong.
304 + pub(crate) fn scroll_view(&mut self, mv: impl FnOnce(&mut Grid) -> bool) {
305 + if mv(&mut self.grid) {
306 + self.dirty = true;
307 + }
308 + }
309 +
310 + /// Hand a pointer event to the program if it asked for the mouse.
311 + ///
312 + /// Returns whether the program took it. `false` means the pointer is still
313 + /// the user's and the caller should do the local thing — select, paste,
314 + /// scroll — so every call site reads as "the program first, then us".
315 + ///
316 + /// Shift is the override, as in every other terminal: holding it keeps the
317 + /// pointer local even under a program that is tracking, which is the only
318 + /// way to select text out of a full-screen application.
319 + fn report_mouse(&mut self, button: MouseButton, action: MouseAction) -> bool {
320 + if self.grid.mouse_tracking() == MouseTracking::Off || self.modifiers.shift {
321 + return false;
322 + }
323 + let at = cell_at(
324 + self.pointer_at,
325 + self.grid.cols(),
326 + self.grid.rows(),
327 + self.cell,
328 + );
329 + // Motion is continuous and reports are per cell, so a move that has
330 + // not left its cell has nothing to say. Presses and releases always
331 + // do, however still the pointer was.
332 + if action == MouseAction::Motion && self.mouse_last_cell == Some(at) {
333 + return true;
334 + }
335 + self.mouse_last_cell = Some(at);
336 + let report = MouseReport {
337 + button,
338 + action,
339 + col: at.col,
340 + row: at.row,
341 + mods: MouseMods {
342 + shift: false,
343 + alt: self.modifiers.alt,
344 + ctrl: self.modifiers.ctrl,
345 + },
346 + };
347 + // The program is tracking either way. A level that did not ask for
348 + // this particular event still owns the pointer, so `true` even when
349 + // there are no bytes: falling through to the local path would select
350 + // text under an application that thinks it holds the mouse.
351 + if let Some(bytes) = self.grid.encode_mouse(report)
352 + && let Err(e) = self.pty.write(&bytes)
353 + {
354 + warn!("pty write (mouse): {e}");
355 + }
356 + true
357 + }
358 +
359 + /// Act on a wheel turn of `notches`, negative for up.
360 + fn apply_wheel(&mut self, notches: i32) {
361 + let modes = shop_xkb::Modes {
362 + cursor_keys_application: self.grid.cursor_keys_application(),
363 + keypad_application: self.grid.keypad_application(),
364 + };
365 + match wheel_action(
366 + notches,
367 + self.grid.on_alt(),
368 + self.grid.alternate_scroll(),
369 + modes,
370 + ) {
371 + WheelAction::Nothing => {}
372 + WheelAction::Up(lines) => self.scroll_view(|g| g.scroll_view_up(lines)),
373 + WheelAction::Down(lines) => self.scroll_view(|g| g.scroll_view_down(lines)),
374 + // No snap back to the bottom here, unlike typing: the viewport is
375 + // already pinned at zero on the alt screen, so there is nothing
376 + // for a reset to do.
377 + WheelAction::Keys(bytes) => {
378 + if let Err(e) = self.pty.write(&bytes) {
379 + warn!("pty write: {e}");
380 + }
381 + }
382 + }
383 + }
384 +
385 + /// Reconcile the selection with what just happened to the grid.
386 + ///
387 + /// A selection is a claim about which cells hold which text, and a scroll
388 + /// or a resize can make that claim false. Scrolling moves it; anything
389 + /// that rewrites the screen wholesale retires it, because there is no
390 + /// honest way to say where the selected text went.
391 + pub(crate) fn apply_damage_to_selection(&mut self, damage: &shop_grid::Damage) {
392 + if self.selection.is_none() {
393 + return;
394 + }
395 + // A viewport move is in the same class as a resize here. The selection
396 + // is anchored to visible rows, and moving the view puts different text
397 + // under them; carrying the anchors across would silently reselect
398 + // something the user never dragged over.
399 + if damage.resized || damage.screen_swapped || damage.view_moved {
400 + self.selection = None;
401 + self.dragging = false;
402 + } else if damage.scroll != 0 {
403 + let rows = self.grid.rows();
404 + self.selection = self
405 + .selection
406 + .and_then(|sel| sel.scrolled(damage.scroll, rows));
407 + if self.selection.is_none() {
408 + self.dragging = false;
409 + }
410 + }
411 + }
412 + }
413 +
414 + #[cfg(test)]
415 + mod tests {
416 + use super::*;
417 + use crate::{FONT_BYTES, FONT_PX};
418 +
419 + fn at(row: u16, col: u16) -> Point {
420 + Point::new(row, col)
421 + }
422 +
423 + /// The real cell, measured off the real bundled face.
424 + ///
425 + /// Not a fixture. Hit-testing against numbers a test made up would pass
426 + /// while the terminal put the pointer in the wrong cell, which is the
427 + /// class of bug this whole change is about.
428 + fn test_cell() -> CellMetrics {
429 + CellMetrics::measure(FONT_BYTES, FONT_PX).expect("the bundled face measures")
430 + }
431 +
432 + // ---- the mouse ------------------------------------------------------
433 +
434 + #[test]
435 + fn the_three_reportable_buttons_have_numbers_and_the_rest_do_not() {
436 + assert_eq!(mouse_button(BTN_LEFT), Some(MouseButton::Left));
437 + assert_eq!(mouse_button(BTN_MIDDLE), Some(MouseButton::Middle));
438 + assert_eq!(mouse_button(BTN_RIGHT), Some(MouseButton::Right));
439 + // A thumb button. There is no report for it, and inventing one would
440 + // tell the program a different button was pressed.
441 + assert_eq!(mouse_button(0x113), None);
442 + }
443 +
444 + #[test]
445 + fn a_wheel_notch_is_up_on_the_same_sign_the_local_scroll_reads() {
446 + // The two paths have to agree, or a program tracking the mouse would
447 + // scroll the opposite way from the same turn of the wheel.
448 + assert!(matches!(
449 + wheel_action(-1, false, true, NORMAL),
450 + WheelAction::Up(_)
451 + ));
452 + assert!(matches!(
453 + wheel_action(1, false, true, NORMAL),
454 + WheelAction::Down(_)
455 + ));
456 + }
457 +
458 + // ---- the wheel ------------------------------------------------------
459 +
460 + const NORMAL: shop_xkb::Modes = shop_xkb::Modes {
461 + cursor_keys_application: false,
462 + keypad_application: false,
463 + };
464 +
465 + fn keys(action: &WheelAction) -> &str {
466 + match action {
467 + WheelAction::Keys(b) => std::str::from_utf8(b).expect("arrow bytes are ascii"),
468 + other => panic!("expected keys, got {other:?}"),
469 + }
470 + }
471 +
472 + #[test]
473 + fn on_the_main_screen_the_wheel_moves_the_viewport() {
474 + assert_eq!(
475 + wheel_action(-1, false, true, NORMAL),
476 + WheelAction::Up(WHEEL_LINES)
477 + );
478 + assert_eq!(
479 + wheel_action(2, false, true, NORMAL),
480 + WheelAction::Down(WHEEL_LINES * 2)
481 + );
482 + }
483 +
484 + #[test]
485 + fn the_main_screen_ignores_1007() {
486 + // The mode only answers what to do where there is no history; it is
487 + // not an off switch for scrolling.
488 + assert_eq!(
489 + wheel_action(-1, false, false, NORMAL),
490 + WheelAction::Up(WHEEL_LINES)
491 + );
492 + }
493 +
494 + #[test]
495 + fn on_the_alt_screen_the_wheel_becomes_arrows() {
496 + // One arrow per row the viewport would have moved, so `less` covers
497 + // the same distance as the scrollback would have.
498 + assert_eq!(
499 + keys(&wheel_action(-1, true, true, NORMAL)),
500 + "\x1b[A".repeat(3)
Lines truncated