max / shop
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
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