Skip to main content

max / shop

PTY loop: fork a shell, plumb output through calloop crates/shop-pty: nix-based PTY spawn. openpty, fork, TIOCSCTTY in the child, dup2 slave onto stdio, execvp shell. Parent gets nonblocking master fd; Drop sends SIGHUP. crates/shop-wayland: reduced to types + raw_handles helper. SCTK setup moved inline to the binary because CompositorState::bind and friends require Dispatch impls that only exist at the delegate_dispatch2 call site. crates/shop: composed App state (chrome + wgpu + text + pty + scrollback). calloop event loop with WaylandSource + Generic(pty fd). PTY callback drains master into a 64 KiB scrollback ring, sets pending_redraw. Naive line layout for now — strips control bytes, renders the last N visible rows. VT parser replaces this next. Verified on fw13: builds clean, bash spawned to steady-state, event loop reached. Visual confirmation of the prompt pending on fw13 display.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 19:19 UTC
Signed with PGP, not checked
Commit: 8d6957f6ee6b8175e4777c2e29e351a1a2f4dc7f
Parent: 5eceb2f
7 files changed, +578 insertions, -252 deletions
M Cargo.lock +26
@@ -457,6 +457,18 @@
457 457 "thiserror",
458 458 ]
459 459
460 + [[package]]
461 + name = "nix"
462 + version = "0.31.3"
463 + source = "registry+https://github.com/rust-lang/crates.io-index"
464 + checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
465 + dependencies = [
466 + "bitflags",
467 + "cfg-if",
468 + "cfg_aliases",
469 + "libc",
470 + ]
471 +
460 472 [[package]]
461 473 name = "nu-ansi-term"
462 474 version = "0.50.3"
@@ -798,14 +810,28 @@
798 810 version = "0.0.1"
799 811 dependencies = [
800 812 "anyhow",
813 + "calloop",
814 + "calloop-wayland-source",
801 815 "pollster",
816 + "shop-pty",
802 817 "shop-render",
803 818 "shop-wayland",
819 + "smithay-client-toolkit",
804 820 "tracing",
805 821 "tracing-subscriber",
822 + "wayland-client",
806 823 "wgpu",
807 824 ]
808 825
826 + [[package]]
827 + name = "shop-pty"
828 + version = "0.0.0"
829 + dependencies = [
830 + "anyhow",
831 + "nix",
832 + "tracing",
833 + ]
834 +
809 835 [[package]]
810 836 name = "shop-render"
811 837 version = "0.0.0"
M Cargo.toml +2
@@ -5,6 +5,7 @@
5 5 "crates/kitty-graphics",
6 6 "crates/shop-wayland",
7 7 "crates/shop-render",
8 + "crates/shop-pty",
8 9 ]
9 10
10 11 [workspace.dependencies]
@@ -21,6 +22,7 @@
21 22 swash = "0.2"
22 23 guillotiere = "0.7"
23 24 bytemuck = { version = "1", features = ["derive"] }
25 + nix = { version = "0.31", features = ["term", "process", "fs", "ioctl", "signal"] }
24 26
25 27 [workspace.package]
26 28 edition = "2024"
@@ -18,6 +18,11 @@
18 18 [dependencies]
19 19 shop-wayland = { path = "../shop-wayland" }
20 20 shop-render = { path = "../shop-render" }
21 + shop-pty = { path = "../shop-pty" }
22 + smithay-client-toolkit.workspace = true
23 + wayland-client.workspace = true
24 + calloop.workspace = true
25 + calloop-wayland-source.workspace = true
21 26 wgpu.workspace = true
22 27 pollster.workspace = true
23 28 anyhow.workspace = true
@@ -1,24 +1,22 @@
1 - //! Wayland window + event queue for shop.
1 + //! Wayland primitives for shop.
2 2 //!
3 - //! Owns the SCTK client-side state (compositor, xdg-shell, output, seat,
4 - //! registry) and exposes a [`Window`] that drives the event queue and reports
5 - //! pending changes via a drain-on-dispatch queue. Renderer-agnostic — no wgpu,
6 - //! no image decoding, no fonts. The binary owns the render loop.
3 + //! Owns the SCTK client-side objects (compositor, xdg-shell, output, seat,
4 + //! registry, top-level window). Does NOT own the wayland event queue or
5 + //! implement any SCTK Handler traits — those live in the binary so a single
6 + //! app state can be dispatched from a shared event loop (calloop), which is
7 + //! also where the PTY fd and any other sources land.
7 8 //!
8 - //! Bootstrapped against SCTK 0.21's `examples/wgpu.rs` structure. See
9 - //! `~/Wiki/shop-overview.md` for the design.
9 + //! Use [`init`] to construct the chrome against a caller-provided queue
10 + //! handle; the caller keeps the [`EventQueue`] to hand to a wayland source.
10 11
11 12 use std::{collections::VecDeque, ptr::NonNull};
12 13
13 - use raw_window_handle::{
14 - RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle,
15 - };
16 - use smithay_client_toolkit::{
14 + pub use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
15 + use raw_window_handle::{WaylandDisplayHandle, WaylandWindowHandle};
16 + pub use smithay_client_toolkit::{
17 17 compositor::{CompositorHandler, CompositorState},
18 - delegate_dispatch2, delegate_registry,
19 18 output::{OutputHandler, OutputState},
20 19 registry::{ProvidesRegistryState, RegistryState},
21 - registry_handlers,
22 20 seat::{Capability, SeatHandler, SeatState},
23 21 shell::{
24 22 WaylandSurface,
@@ -28,17 +26,16 @@
28 26 },
29 27 },
30 28 };
31 - use wayland_client::{
32 - Connection, EventQueue, Proxy, QueueHandle,
33 - globals::registry_queue_init,
34 - protocol::{wl_output, wl_seat, wl_surface},
29 + pub use wayland_client::{
30 + Connection, Dispatch, EventQueue, Proxy, QueueHandle,
31 + globals::{GlobalList, GlobalListContents, registry_queue_init},
32 + protocol::wl_registry::WlRegistry,
35 33 };
36 34
37 35 /// Change reported by the compositor since the last dispatch.
38 36 #[derive(Debug, Clone)]
39 37 pub enum Pending {
40 - /// The compositor gave us a new size (may equal the previous size — treat
41 - /// the first Resized after startup as "now safe to draw").
38 + /// The compositor gave us a new size.
42 39 Resized { width: u32, height: u32 },
43 40 /// User asked to close (xdg-toplevel close).
44 41 CloseRequested,
@@ -52,195 +49,36 @@
52 49 pub initial_size: (u32, u32),
53 50 }
54 51
55 - /// Owns the SCTK state and the wayland event queue.
56 - pub struct Window {
57 - conn: Connection,
58 - queue: EventQueue<State>,
59 - state: State,
52 + /// Chrome (SCTK client-side state) for one top-level window. Composed into
53 + /// the binary's app state so SCTK Handler traits can be implemented once at
54 + /// the app layer and dispatched by calloop.
55 + pub struct WaylandChrome {
56 + pub registry: RegistryState,
57 + pub seat: SeatState,
58 + pub output: OutputState,
59 + pub compositor: CompositorState,
60 + pub xdg_shell: XdgShell,
61 + pub xdg_window: XdgWindow,
62 + pub pending: VecDeque<Pending>,
63 + pub width: u32,
64 + pub height: u32,
65 + pub exit: bool,
60 66 }
61 67
62 - struct State {
63 - registry: RegistryState,
64 - seat: SeatState,
65 - output: OutputState,
66 - _compositor: CompositorState,
67 - _xdg_shell: XdgShell,
68 - xdg_window: XdgWindow,
69 - pending: VecDeque<Pending>,
70 - width: u32,
71 - height: u32,
72 - exit: bool,
68 + // Chrome construction lives in the binary because the SCTK `bind` calls need
69 + // all the Dispatch impls that `delegate_dispatch2!(App)` provides, and those
70 + // impls are only visible in the crate that invokes the macro.
71 +
72 +
73 + /// Raw handles for the compositor connection and the top-level surface.
74 + /// Both reference wayland pointers owned by `conn`/`chrome`; do not use
75 + /// after either is dropped.
76 + pub fn raw_handles(conn: &Connection, chrome: &WaylandChrome) -> (RawDisplayHandle, RawWindowHandle) {
77 + let display_ptr =
78 + NonNull::new(conn.backend().display_ptr().cast()).expect("wayland display ptr");
79 + let surface_ptr = NonNull::new(chrome.xdg_window.wl_surface().id().as_ptr().cast())
80 + .expect("wl_surface ptr");
81 + let display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(display_ptr));
82 + let window = RawWindowHandle::Wayland(WaylandWindowHandle::new(surface_ptr));
83 + (display, window)
73 84 }
74 -
75 - impl Window {
76 - /// Connect to the compositor and create the top-level window. The first
77 - /// `dispatch()` call after this will typically yield a `Pending::Resized`
78 - /// carrying the compositor's chosen initial size.
79 - pub fn new(spec: WindowSpec<'_>) -> anyhow::Result<Self> {
80 - let conn = Connection::connect_to_env()?;
81 - let (globals, queue) = registry_queue_init(&conn)?;
82 - let qh = queue.handle();
83 -
84 - let compositor = CompositorState::bind(&globals, &qh)?;
85 - let xdg_shell = XdgShell::bind(&globals, &qh)?;
86 -
87 - let surface = compositor.create_surface(&qh);
88 - let xdg_window = xdg_shell.create_window(surface, WindowDecorations::ServerDefault, &qh);
89 - xdg_window.set_title(spec.title.to_string());
90 - xdg_window.set_app_id(spec.app_id.to_string());
91 - xdg_window.set_min_size(Some(spec.min_size));
92 - xdg_window.commit();
93 -
94 - let state = State {
95 - registry: RegistryState::new(&globals),
96 - seat: SeatState::new(&globals, &qh),
97 - output: OutputState::new(&globals, &qh),
98 - _compositor: compositor,
99 - _xdg_shell: xdg_shell,
100 - xdg_window,
101 - pending: VecDeque::new(),
102 - width: spec.initial_size.0,
103 - height: spec.initial_size.1,
104 - exit: false,
105 - };
106 -
107 - Ok(Self { conn, queue, state })
108 - }
109 -
110 - /// Raw display + window handles for a graphics API (wgpu, GL).
111 - ///
112 - /// Returned handles reference wayland pointers owned by this window; do
113 - /// not use them after the `Window` is dropped.
114 - pub fn raw_handles(&self) -> (RawDisplayHandle, RawWindowHandle) {
115 - let display_ptr =
116 - NonNull::new(self.conn.backend().display_ptr().cast()).expect("wayland display ptr");
117 - let surface_ptr = NonNull::new(self.state.xdg_window.wl_surface().id().as_ptr().cast())
118 - .expect("wl_surface ptr");
119 - let display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(display_ptr));
120 - let window = RawWindowHandle::Wayland(WaylandWindowHandle::new(surface_ptr));
121 - (display, window)
122 - }
123 -
124 - /// Blocking-dispatch one round of wayland events and drain accumulated
125 - /// pending changes.
126 - pub fn dispatch(&mut self) -> anyhow::Result<Vec<Pending>> {
127 - self.queue.blocking_dispatch(&mut self.state)?;
128 - Ok(self.state.pending.drain(..).collect())
129 - }
130 -
131 - pub fn should_exit(&self) -> bool {
132 - self.state.exit
133 - }
134 -
135 - pub fn size(&self) -> (u32, u32) {
136 - (self.state.width, self.state.height)
137 - }
138 - }
139 -
140 - impl CompositorHandler for State {
141 - fn scale_factor_changed(
142 - &mut self,
143 - _: &Connection,
144 - _: &QueueHandle<Self>,
145 - _: &wl_surface::WlSurface,
146 - _: i32,
147 - ) {
148 - }
149 -
150 - fn transform_changed(
151 - &mut self,
152 - _: &Connection,
153 - _: &QueueHandle<Self>,
154 - _: &wl_surface::WlSurface,
155 - _: wl_output::Transform,
156 - ) {
157 - }
158 -
159 - fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
160 -
161 - fn surface_enter(
162 - &mut self,
163 - _: &Connection,
164 - _: &QueueHandle<Self>,
165 - _: &wl_surface::WlSurface,
166 - _: &wl_output::WlOutput,
167 - ) {
168 - }
169 -
170 - fn surface_leave(
171 - &mut self,
172 - _: &Connection,
173 - _: &QueueHandle<Self>,
174 - _: &wl_surface::WlSurface,
175 - _: &wl_output::WlOutput,
176 - ) {
177 - }
178 - }
179 -
180 - impl OutputHandler for State {
181 - fn output_state(&mut self) -> &mut OutputState {
182 - &mut self.output
183 - }
184 -
185 - fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
186 - fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
187 - fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
188 - }
189 -
190 - impl SeatHandler for State {
191 - fn seat_state(&mut self) -> &mut SeatState {
192 - &mut self.seat
193 - }
194 -
195 - fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
196 - fn new_capability(
197 - &mut self,
198 - _: &Connection,
199 - _: &QueueHandle<Self>,
200 - _: wl_seat::WlSeat,
201 - _: Capability,
202 - ) {
203 - }
204 - fn remove_capability(
205 - &mut self,
206 - _: &Connection,
207 - _: &QueueHandle<Self>,
208 - _: wl_seat::WlSeat,
209 - _: Capability,
210 - ) {
211 - }
212 - fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
213 - }
214 -
215 - impl WindowHandler for State {
216 - fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &XdgWindow) {
217 - self.exit = true;
218 - self.pending.push_back(Pending::CloseRequested);
219 - }
220 -
221 - fn configure(
222 - &mut self,
223 - _: &Connection,
224 - _: &QueueHandle<Self>,
225 - _: &XdgWindow,
226 - configure: WindowConfigure,
227 - _serial: u32,
228 - ) {
229 - let (new_w, new_h) = configure.new_size;
230 - let width = new_w.map_or(self.width, std::num::NonZeroU32::get);
231 - let height = new_h.map_or(self.height, std::num::NonZeroU32::get);
232 - self.width = width;
233 - self.height = height;
234 - self.pending.push_back(Pending::Resized { width, height });
235 - }
236 - }
237 -
238 - impl ProvidesRegistryState for State {
239 - fn registry(&mut self) -> &mut RegistryState {
240 - &mut self.registry
241 - }
242 - registry_handlers![OutputState];
243 - }
244 -
245 - delegate_registry!(State);
246 - delegate_dispatch2!(State);
@@ -1,22 +1,36 @@
1 1 //! shop — Wayland terminal emulator.
2 2 //!
3 - //! Milestone: glyph on screen. Loads DejaVu Sans Mono, shapes a hardcoded
4 - //! string with swash (ligatures on), rasterizes into a guillotiere-managed
5 - //! wgpu atlas, draws it with an instanced text pipeline.
6 - //!
7 - //! Run on a Wayland compositor:
8 - //! ```text
9 - //! RUST_LOG=info cargo run -p shop
10 - //! ```
3 + //! Milestone: PTY loop. calloop-driven event loop with two sources — the
4 + //! wayland event queue and the PTY master fd. Bytes read from the shell are
5 + //! appended to a rolling buffer and rendered as raw text (no VT parser yet,
6 + //! so escape sequences will show as garbage — vim/htop won't look right, but
7 + //! `ls`, `echo`, `printf` will).
11 8
9 + use std::os::fd::{AsFd, AsRawFd};
10 + use std::sync::Arc;
11 +
12 + use calloop::EventLoop;
13 + use calloop::generic::{FdWrapper, Generic};
14 + use calloop_wayland_source::WaylandSource;
15 + use shop_pty::{Pty, PtySize};
12 16 use shop_render::TextRenderer;
13 - use shop_wayland::{Pending, Window, WindowSpec};
17 + use shop_wayland::{
18 + Capability, CompositorHandler, CompositorState, Connection, OutputHandler, OutputState,
19 + Pending, ProvidesRegistryState, QueueHandle, RegistryState, SeatHandler, SeatState,
20 + WaylandChrome, WaylandSurface, WindowConfigure, WindowDecorations, WindowHandler, WindowSpec,
21 + XdgShell, XdgWindow, registry_queue_init,
22 + };
23 + use smithay_client_toolkit::{delegate_dispatch2, delegate_registry, registry_handlers};
24 + use std::collections::VecDeque;
14 25 use tracing::{info, warn};
26 + use wayland_client::protocol::{wl_output, wl_seat, wl_surface};
15 27
16 28 const INITIAL: (u32, u32) = (960, 540);
17 29 const FONT_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf";
18 - const FONT_PX: f32 = 42.0;
19 - const HEADLINE: &str = "shop — first glyphs";
30 + const FONT_PX: f32 = 18.0;
31 + const CELL_ADVANCE: f32 = 11.0;
32 + const CELL_HEIGHT: f32 = 22.0;
33 + const SCROLLBACK_BYTES: usize = 64 * 1024;
20 34 const CLEAR: wgpu::Color = wgpu::Color {
21 35 r: 0.043,
22 36 g: 0.055,
@@ -36,20 +50,60 @@
36 50 let font_data = std::fs::read(FONT_PATH)
37 51 .map_err(|e| anyhow::anyhow!("read font {FONT_PATH}: {e}"))?;
38 52
39 - let mut window = Window::new(WindowSpec {
53 + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into());
54 + let cols_initial: u16 = (INITIAL.0 as f32 / CELL_ADVANCE) as u16;
55 + let rows_initial: u16 = (INITIAL.1 as f32 / CELL_HEIGHT) as u16;
56 + let pty = Pty::spawn(
57 + &shell,
58 + &[],
59 + PtySize {
60 + cols: cols_initial,
61 + rows: rows_initial,
62 + cell_width: CELL_ADVANCE.round() as u16,
63 + cell_height: CELL_HEIGHT.round() as u16,
64 + },
65 + "xterm-256color",
66 + )?;
67 + info!(shell = %shell, "spawned shell");
68 +
69 + let conn = Connection::connect_to_env()?;
70 + let spec = WindowSpec {
40 71 title: "shop",
41 72 app_id: "dev.makecreative.shop",
42 73 min_size: (320, 240),
43 74 initial_size: INITIAL,
44 - })?;
75 + };
76 + let (globals, event_queue) = registry_queue_init::<App>(&conn)?;
77 + let qh = event_queue.handle();
78 + let compositor = CompositorState::bind(&globals, &qh)?;
79 + let xdg_shell = XdgShell::bind(&globals, &qh)?;
80 + let wl_surface = compositor.create_surface(&qh);
81 + let xdg_window =
82 + xdg_shell.create_window(wl_surface, WindowDecorations::ServerDefault, &qh);
83 + xdg_window.set_title(spec.title.to_string());
84 + xdg_window.set_app_id(spec.app_id.to_string());
85 + xdg_window.set_min_size(Some(spec.min_size));
86 + xdg_window.commit();
87 + let chrome = WaylandChrome {
88 + registry: RegistryState::new(&globals),
89 + seat: SeatState::new(&globals, &qh),
90 + output: OutputState::new(&globals, &qh),
91 + compositor,
92 + xdg_shell,
93 + xdg_window,
94 + pending: VecDeque::new(),
95 + width: spec.initial_size.0,
96 + height: spec.initial_size.1,
97 + exit: false,
98 + };
45 99
46 - let (raw_display, raw_window) = window.raw_handles();
100 + let (raw_display, raw_window) = shop_wayland::raw_handles(&conn, &chrome);
47 101 let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle();
48 102 instance_desc.backends = wgpu::Backends::VULKAN;
49 103 let instance = wgpu::Instance::new(instance_desc);
50 104
51 105 // SAFETY: raw_display / raw_window reference wayland pointers owned by
52 - // `window`; both stay alive until `window` is dropped at the end of main.
106 + // `conn` / `chrome`, both of which live for the rest of main.
53 107 #[allow(unsafe_code)]
54 108 let surface = unsafe {
55 109 instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle {
@@ -70,7 +124,7 @@
70 124
71 125 let caps = surface.get_capabilities(&adapter);
72 126 let format = caps.formats[0];
73 - let mut config = wgpu::SurfaceConfiguration {
127 + let surface_config = wgpu::SurfaceConfiguration {
74 128 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
75 129 format,
76 130 color_space: wgpu::SurfaceColorSpace::Auto,
@@ -81,48 +135,134 @@
81 135 desired_maximum_frame_latency: 2,
82 136 present_mode: wgpu::PresentMode::Mailbox,
83 137 };
138 + let text = TextRenderer::new(&device, format, font_data, FONT_PX)?;
84 139
85 - let mut text = TextRenderer::new(&device, format, font_data, FONT_PX)?;
140 + let mut app = App {
141 + chrome,
142 + surface,
143 + surface_config,
144 + device: Arc::new(device),
145 + queue: Arc::new(queue),
146 + text,
147 + pty,
148 + scrollback: Vec::with_capacity(SCROLLBACK_BYTES),
149 + pending_redraw: true,
150 + };
151 + app.surface.configure(&app.device, &app.surface_config);
152 + app.text
153 + .resize(&app.queue, app.surface_config.width, app.surface_config.height);
86 154
87 - info!("entering event loop");
88 - while !window.should_exit() {
89 - for change in window.dispatch()? {
90 - match change {
91 - Pending::Resized { width, height } => {
92 - config.width = width;
93 - config.height = height;
94 - surface.configure(&device, &config);
95 - text.resize(&queue, width, height);
96 - if let Err(e) = draw(&surface, &device, &queue, &mut text) {
97 - warn!("draw failed: {e:?}");
155 + // calloop event loop with wayland + PTY sources.
156 + let mut event_loop: EventLoop<App> = EventLoop::try_new()?;
157 + let loop_handle = event_loop.handle();
158 +
159 + WaylandSource::new(conn, event_queue)
160 + .insert(loop_handle.clone())
161 + .map_err(|e| anyhow::anyhow!("insert wayland source: {e}"))?;
162 +
163 + // Register the PTY fd. Level-triggered so we drain in the callback.
164 + // SAFETY: `raw_fd` refers to `app.pty`'s master fd, which outlives the
165 + // event loop (both live until end of main).
166 + #[allow(unsafe_code)]
167 + let pty_source = {
168 + let raw_fd = app.pty.as_fd().as_raw_fd();
169 + let wrapper = unsafe { FdWrapper::new(raw_fd) };
170 + Generic::new(wrapper, calloop::Interest::READ, calloop::Mode::Level)
171 + };
172 + loop_handle
173 + .insert_source(pty_source, |_readiness, _fd, app| {
174 + let mut buf = [0u8; 4096];
175 + loop {
176 + match app.pty.read(&mut buf) {
177 + Ok(0) => {
178 + app.chrome.exit = true;
179 + break;
180 + }
181 + Ok(n) => {
182 + push_scrollback(&mut app.scrollback, &buf[..n]);
183 + app.pending_redraw = true;
184 + }
185 + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
186 + Err(e) => {
187 + warn!("pty read: {e}");
188 + break;
98 189 }
99 190 }
100 - Pending::CloseRequested => info!("close requested"),
101 191 }
192 + Ok(calloop::PostAction::Continue)
193 + })
194 + .map_err(|e| anyhow::anyhow!("insert pty source: {e}"))?;
195 +
196 + info!("entering event loop");
197 + while !app.chrome.exit {
198 + event_loop.dispatch(None, &mut app)?;
199 +
200 + for change in app.chrome.pending.drain(..).collect::<Vec<_>>() {
201 + match change {
202 + Pending::Resized { width, height } => {
203 + app.surface_config.width = width;
204 + app.surface_config.height = height;
205 + app.surface.configure(&app.device, &app.surface_config);
206 + app.text.resize(&app.queue, width, height);
207 + let cols = (width as f32 / CELL_ADVANCE).max(1.0) as u16;
208 + let rows = (height as f32 / CELL_HEIGHT).max(1.0) as u16;
209 + let _ = app.pty.resize(PtySize {
210 + cols,
211 + rows,
212 + cell_width: CELL_ADVANCE.round() as u16,
213 + cell_height: CELL_HEIGHT.round() as u16,
214 + });
215 + app.pending_redraw = true;
216 + }
217 + Pending::CloseRequested => app.chrome.exit = true,
218 + }
219 + }
220 +
221 + if app.pending_redraw && !app.chrome.exit {
222 + if let Err(e) = draw(&mut app) {
223 + warn!("draw: {e:?}");
224 + }
225 + app.pending_redraw = false;
102 226 }
103 227 }
104 228
105 - drop(surface);
106 - drop(window);
107 229 Ok(())
108 230 }
109 231
110 - fn draw(
111 - surface: &wgpu::Surface<'_>,
112 - device: &wgpu::Device,
113 - queue: &wgpu::Queue,
114 - text: &mut TextRenderer,
115 - ) -> anyhow::Result<()> {
116 - let frame = match surface.get_current_texture() {
232 + /// Append bytes and trim from the left when past the scrollback cap.
233 + fn push_scrollback(buf: &mut Vec<u8>, bytes: &[u8]) {
234 + buf.extend_from_slice(bytes);
235 + if buf.len() > SCROLLBACK_BYTES {
236 + let drop = buf.len() - SCROLLBACK_BYTES;
237 + buf.drain(..drop);
238 + }
239 + }
240 +
241 + struct App {
242 + chrome: WaylandChrome,
243 + surface: wgpu::Surface<'static>,
244 + surface_config: wgpu::SurfaceConfiguration,
245 + device: Arc<wgpu::Device>,
246 + queue: Arc<wgpu::Queue>,
247 + text: TextRenderer,
248 + pty: Pty,
249 + scrollback: Vec<u8>,
250 + pending_redraw: bool,
251 + }
252 +
253 + fn draw(app: &mut App) -> anyhow::Result<()> {
254 + let frame = match app.surface.get_current_texture() {
117 255 wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
118 256 other => anyhow::bail!("surface acquire: {other:?}"),
119 257 };
120 258 let view = frame
121 259 .texture
122 260 .create_view(&wgpu::TextureViewDescriptor::default());
123 - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
124 - label: Some("shop.frame"),
125 - });
261 + let mut encoder =
262 + app.device
263 + .create_command_encoder(&wgpu::CommandEncoderDescriptor {
264 + label: Some("shop.frame"),
265 + });
126 266 {
127 267 let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
128 268 label: Some("shop.clear"),
@@ -138,8 +278,134 @@
138 278 ..Default::default()
139 279 });
140 280 }
141 - text.draw(device, queue, &view, &mut encoder, HEADLINE, 48.0, 40.0, TEXT_COLOR);
142 - queue.submit(std::iter::once(encoder.finish()));
143 - queue.present(frame);
281 + // Naive layout: split scrollback into lines, keep the last N that fit,
282 + // strip control bytes, draw each. The VT parser milestone replaces this.
283 + let text = String::from_utf8_lossy(&app.scrollback);
284 + let visible_rows = ((app.surface_config.height as f32 / CELL_HEIGHT) as usize).max(1);
285 + let lines: Vec<&str> = text.lines().collect();
286 + let start = lines.len().saturating_sub(visible_rows);
287 + let mut y = 4.0;
288 + for line in &lines[start..] {
289 + let sanitized: String = line.chars().filter(|c| !c.is_control()).collect();
290 + if !sanitized.is_empty() {
291 + app.text.draw(
292 + &app.device,
293 + &app.queue,
294 + &view,
295 + &mut encoder,
296 + &sanitized,
297 + 4.0,
298 + y,
299 + TEXT_COLOR,
300 + );
301 + }
302 + y += CELL_HEIGHT;
303 + }
304 + app.queue.submit(std::iter::once(encoder.finish()));
305 + app.queue.present(frame);
144 306 Ok(())
145 307 }
308 +
309 + // -- SCTK Handler impls on the composed App state --------------------------
310 +
311 + impl CompositorHandler for App {
312 + fn scale_factor_changed(
313 + &mut self,
314 + _: &Connection,
315 + _: &QueueHandle<Self>,
316 + _: &wl_surface::WlSurface,
317 + _: i32,
318 + ) {
319 + }
320 + fn transform_changed(
321 + &mut self,
322 + _: &Connection,
323 + _: &QueueHandle<Self>,
324 + _: &wl_surface::WlSurface,
325 + _: wl_output::Transform,
326 + ) {
327 + }
328 + fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
329 + fn surface_enter(
330 + &mut self,
331 + _: &Connection,
332 + _: &QueueHandle<Self>,
333 + _: &wl_surface::WlSurface,
334 + _: &wl_output::WlOutput,
335 + ) {
336 + }
337 + fn surface_leave(
338 + &mut self,
339 + _: &Connection,
340 + _: &QueueHandle<Self>,
341 + _: &wl_surface::WlSurface,
342 + _: &wl_output::WlOutput,
343 + ) {
344 + }
345 + }
346 +
347 + impl OutputHandler for App {
348 + fn output_state(&mut self) -> &mut OutputState {
349 + &mut self.chrome.output
350 + }
351 + fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
352 + fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
353 + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
354 + }
355 +
356 + impl SeatHandler for App {
357 + fn seat_state(&mut self) -> &mut SeatState {
358 + &mut self.chrome.seat
359 + }
360 + fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
361 + fn new_capability(
362 + &mut self,
363 + _: &Connection,
364 + _: &QueueHandle<Self>,
365 + _: wl_seat::WlSeat,
366 + _: Capability,
367 + ) {
368 + }
369 + fn remove_capability(
370 + &mut self,
371 + _: &Connection,
372 + _: &QueueHandle<Self>,
373 + _: wl_seat::WlSeat,
374 + _: Capability,
375 + ) {
376 + }
377 + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
378 + }
379 +
380 + impl WindowHandler for App {
381 + fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &XdgWindow) {
382 + self.chrome.exit = true;
383 + self.chrome.pending.push_back(Pending::CloseRequested);
384 + }
385 +
386 + fn configure(
387 + &mut self,
388 + _: &Connection,
389 + _: &QueueHandle<Self>,
390 + _: &XdgWindow,
391 + configure: WindowConfigure,
392 + _serial: u32,
393 + ) {
394 + let (new_w, new_h) = configure.new_size;
395 + let width = new_w.map_or(self.chrome.width, std::num::NonZeroU32::get);
396 + let height = new_h.map_or(self.chrome.height, std::num::NonZeroU32::get);
397 + self.chrome.width = width;
398 + self.chrome.height = height;
399 + self.chrome.pending.push_back(Pending::Resized { width, height });
400 + }
401 + }
402 +
403 + impl ProvidesRegistryState for App {
404 + fn registry(&mut self) -> &mut RegistryState {
405 + &mut self.chrome.registry
406 + }
407 + registry_handlers![OutputState];
408 + }
409 +
410 + delegate_registry!(App);
411 + delegate_dispatch2!(App);
@@ -1,0 +1,18 @@
1 + [package]
2 + name = "shop-pty"
3 + version = "0.0.0"
4 + description = "PTY spawning + I/O for shop (Linux)"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + nix.workspace = true
17 + anyhow.workspace = true
18 + tracing.workspace = true
@@ -1,0 +1,171 @@
1 + //! PTY spawning + I/O for shop.
2 + //!
3 + //! Opens a Linux pseudo-terminal, forks, execs a shell in the child with the
4 + //! slave as controlling terminal, and hands the parent a nonblocking master
5 + //! fd for read/write. The master fd implements [`AsFd`] so callers can plug
6 + //! it into any Unix event loop (calloop, mio, epoll).
7 + //!
8 + //! Reference: rio's teletypewriter/src/unix/mod.rs (MIT). Not a line-for-line
9 + //! port — we use nix's safe wrappers rather than raw libc, and skip the
10 + //! corcovado (mio 0.6) coupling entirely.
11 +
12 + use std::ffi::CString;
13 + use std::io;
14 + use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
15 +
16 + use nix::fcntl::{FcntlArg, OFlag, fcntl};
17 + use nix::libc;
18 + use nix::pty::{Winsize, openpty};
19 + use nix::sys::signal::{Signal, kill};
20 + use nix::unistd::{ForkResult, Pid, execvp, fork, setsid};
21 +
22 + /// Dimensions of the terminal surface reported to the kernel via TIOCSWINSZ.
23 + #[derive(Debug, Clone, Copy)]
24 + pub struct PtySize {
25 + pub cols: u16,
26 + pub rows: u16,
27 + pub cell_width: u16,
28 + pub cell_height: u16,
29 + }
30 +
31 + impl PtySize {
32 + fn to_winsize(self) -> Winsize {
33 + Winsize {
34 + ws_row: self.rows,
35 + ws_col: self.cols,
36 + ws_xpixel: self.cols.saturating_mul(self.cell_width),
37 + ws_ypixel: self.rows.saturating_mul(self.cell_height),
38 + }
39 + }
40 + }
41 +
42 + /// A running shell attached to a PTY. Dropping this sends SIGHUP to the
43 + /// child.
44 + pub struct Pty {
45 + master: OwnedFd,
46 + child: Pid,
47 + }
48 +
49 + impl Pty {
50 + /// Fork a child running `command`, hand the child an already-set-up
51 + /// controlling terminal, and return the parent-side master fd.
52 + ///
53 + /// `term` controls the value of the `TERM` env var for the child; the
54 + /// shell/programs use it to select escape sequences. Start with
55 + /// `"xterm-256color"` until we have a terminfo entry for shop.
56 + pub fn spawn(
57 + command: &str,
58 + args: &[&str],
59 + size: PtySize,
60 + term: &str,
61 + ) -> anyhow::Result<Self> {
62 + let ws = size.to_winsize();
63 + let pair = openpty(Some(&ws), None)?;
64 +
65 + // SAFETY: post-fork, the child only calls async-signal-safe libc
66 + // calls (setsid, dup2, ioctl, execvp) plus setenv (not strictly
67 + // signal-safe, tolerated by GLIBC for terminal setup).
68 + #[allow(unsafe_code)]
69 + match unsafe { fork() }? {
70 + ForkResult::Parent { child } => {
71 + drop(pair.slave);
72 + let flags = fcntl(pair.master.as_fd(), FcntlArg::F_GETFL)?;
73 + let nb = OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK;
74 + fcntl(pair.master.as_fd(), FcntlArg::F_SETFL(nb))?;
75 + Ok(Self {
76 + master: pair.master,
77 + child,
78 + })
79 + }
80 + ForkResult::Child => {
81 + run_child(pair.slave, command, args, term);
82 + }
83 + }
84 + }
85 +
86 + /// Send SIGWINCH to the child after updating the master's window size.
87 + pub fn resize(&self, size: PtySize) -> anyhow::Result<()> {
88 + let ws = size.to_winsize();
89 + // SAFETY: master.as_raw_fd() is a valid fd for our process; ioctl
90 + // TIOCSWINSZ reads Winsize by pointer.
91 + #[allow(unsafe_code)]
92 + let rc = unsafe {
93 + libc::ioctl(
94 + self.master.as_raw_fd(),
95 + libc::TIOCSWINSZ,
96 + std::ptr::from_ref(&ws),
97 + )
98 + };
99 + if rc != 0 {
100 + anyhow::bail!(io::Error::last_os_error());
101 + }
102 + Ok(())
103 + }
104 +
105 + /// Nonblocking read. Returns `Ok(0)` on EOF, `WouldBlock` when there's
106 + /// nothing to read yet.
107 + pub fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
108 + nix::unistd::read(&self.master, buf).map_err(io::Error::from)
109 + }
110 +
111 + /// Write bytes to the shell (e.g. keystrokes).
112 + pub fn write(&self, buf: &[u8]) -> io::Result<usize> {
113 + nix::unistd::write(&self.master, buf).map_err(io::Error::from)
114 + }
115 +
116 + pub fn child(&self) -> Pid {
117 + self.child
118 + }
119 + }
120 +
121 + impl AsFd for Pty {
122 + fn as_fd(&self) -> BorrowedFd<'_> {
123 + self.master.as_fd()
124 + }
125 + }
126 +
127 + impl Drop for Pty {
128 + fn drop(&mut self) {
129 + let _ = kill(self.child, Signal::SIGHUP);
130 + }
131 + }
132 +
133 + fn run_child(slave: OwnedFd, command: &str, args: &[&str], term: &str) -> ! {
134 + // New session detaches us from the parent's controlling terminal.
135 + let _ = setsid();
136 +
137 + // Make the slave our controlling terminal.
138 + #[allow(unsafe_code)]
139 + unsafe {
140 + libc::ioctl(slave.as_raw_fd(), libc::TIOCSCTTY as _, 0);
141 + }
142 +
143 + // Duplicate slave onto stdin/stdout/stderr. libc::dup2 rather than nix's
144 + // typed version, which needs an owned destination (unusable for stdio).
145 + // SAFETY: slave.as_raw_fd() is valid; STDIN/OUT/ERR_FILENO are constants.
146 + let slave_raw = slave.as_raw_fd();
147 + #[allow(unsafe_code)]
148 + unsafe {
149 + libc::dup2(slave_raw, libc::STDIN_FILENO);
150 + libc::dup2(slave_raw, libc::STDOUT_FILENO);
151 + libc::dup2(slave_raw, libc::STDERR_FILENO);
152 + }
153 + drop(slave);
154 +
155 + // TERM is the classic escape-set selector; SHELL is informational.
156 + // SAFETY: single-threaded child process, no other threads to race.
157 + #[allow(unsafe_code)]
158 + unsafe {
159 + std::env::set_var("TERM", term);
160 + }
161 +
162 + let cmd_c = CString::new(command).expect("command has NUL");
163 + let mut arg_cstrs: Vec<CString> = Vec::with_capacity(args.len() + 1);
164 + arg_cstrs.push(cmd_c.clone());
165 + for a in args {
166 + arg_cstrs.push(CString::new(*a).expect("arg has NUL"));
167 + }
168 + let _ = execvp(&cmd_c, &arg_cstrs);
169 + // execvp only returns on failure.
170 + std::process::exit(127);
171 + }