Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Patin is a native Rust toolkit for building Wayland graphical shells. It provides focused shell infrastructure; a consuming project decides which bars, overlays, launchers, notifications, lock screens, or navigation surfaces exist.

Patin-powered shells are clients, not compositors. The toolkit is tested alongside 0xin, but uses standard Wayland protocols so its core shell remains useful with other layer-shell compositors. When 0xin gains a control socket, Patin will access compositor-specific workspace state and commands through a replaceable adapter.

Toolkit boundary

The library owns Wayland layer surfaces, scaling, seats, pointer/touch routing, shared-memory rendering, logical layout primitives, draw commands, and damage submission. It does not instantiate a particular shell composition or service.

Examples are executable fixtures. The demo bar intentionally contains a clock and optional battery, volume, brightness, and network status readers so the library can be exercised on real systems. Those features are not part of Patin’s default behavior and do not become required dependencies for consumers.

Patin remains a shell-focused toolkit rather than a general-purpose application GUI framework, configuration language, or reactive hot-reload runtime.

The work is split into small visible stages. Every completed stage explains the concepts it introduces, the files and important functions it changes, and the commands that actually verified it.

Architecture

Patin is a library organized around narrow shell-toolkit boundaries:

  • Platform owns Wayland connections, globals, outputs, seats, input events, layer surfaces, shared-memory buffers, and the event loop.
  • Render turns a UI scene into pixels. The first backend uses wl_shm and tiny-skia; text is shaped and rasterized with cosmic-text.
  • UI owns internal geometry, row/column/stack layout, style resolution, hit-testing, and damage collection.
  • Service adapters are optional, out-of-tree crates that implement patin::service::Provider against one system service (D-Bus or otherwise). Patin never constructs one; a consumer depends on and instantiates the ones it wants.
  • Consumers own components, services, and compositions. The demo bar is one consumer used to verify the library. patin-lock is another consumer and is built/launched independently. patin-launcher is a third: an ordinary overlay-layer consumer with no compositor-specific integration. patin-session is another finite overlay consumer; compositor-specific logout commands are injected by its launching environment.
  • Compositor integration exposes workspace state and commands through a replaceable adapter. Its neutral implementation works without compositor IPC; a later 0xin adapter will use the documented control socket.

Data flow

The platform event loop receives Wayland and timer events and calls a consumer implementation of Shell. The consumer updates its state, returns logical draw commands and damage, and decides how input positions affect its composition. Wayland buffer release and frame callbacks determine when storage can be reused and when another frame should be submitted.

Finite and scrollable compositions use defaulted Shell lifecycle and vertical-scroll hooks. The platform translates pointer-axis input and touch drags into that capability; consumers that do not implement it are unchanged.

Calloop dispatches the Wayland event queue and a general consumer update tick. SCTK owns protocol state and shared-memory slots. Configure, scale, input, or consumer updates can mark a surface for redraw; frame callbacks ensure Patin does not submit another frame while one is pending.

CpuRenderer owns tiny-skia, the cosmic-text font system, and its glyph cache. It receives only a byte canvas, physical dimensions, scale, and a list of renderer-neutral fill/text commands produced by the UI scene. Layout, component state, hit-testing, and style stay outside the renderer. Wayland protocol objects also stay outside it. Tiny-skia produces premultiplied RGBA pixels internally, which the renderer converts to little-endian Wayland ARGB8888 when copying into wl_shm.

Logical surface size and physical buffer size are separate. Fractional scale is represented in protocol-native 120ths, physical dimensions are rounded upward, and wp_viewport maps that buffer back to the compositor-provided logical surface size.

SCTK’s seat state discovers pointer and touch capabilities at runtime. Patin creates one protocol object per advertised capability and releases it when the capability or seat disappears. Both input paths receive surface-local logical coordinates and call the same pure rectangle hit test. Successful activations change component state and enter the existing frame-coalesced redraw path. Every touch contact is handled independently. Active contacts are keyed by their touch protocol object and contact ID, so overlapping contacts remain distinct across seats.

The consumer supplies LayerConfig: namespace, layer level, anchors, logical size, exclusive zone, and keyboard policy. The demo chooses a top exclusive bar with keyboard policy None; the toolkit does not choose those values. Consumers may return true from Shell::close_requested when their lifecycle is complete. The platform then leaves its event loop cleanly; persistent compositions inherit the default false implementation.

Session-lock composition

crates/patin-lock uses SCTK’s ext-session-lock-v1 support directly because a lock surface has stricter lifecycle rules than an ordinary layer surface. It creates one lock surface for every output discovered at runtime and adds or removes surfaces as outputs change. All surfaces share one LockUi state and the toolkit CPU renderer.

Seat capabilities are also discovered dynamically. Physical keyboard, pointer, and touch events all feed the same password model and hit-testable keyboard, which is either the QWERTY/symbol layout or a numeric PIN grid depending on --keypad=full|numeric (default full, PATIN_LOCK_KEYPAD sets the default without passing the flag, and an explicit flag always wins). Password storage is bounded and zeroized when cleared or dropped. PAM authentication runs on a worker thread so the Wayland event loop continues to redraw and service compositor events.

If the compositor advertises zwlr_output_power_manager_v1, patin-lock binds it and requests every output be powered off and stops drawing after a period without a real key/touch/pointer press — 1 second before the display has ever been woken, 5 seconds from the moment of any wake onward (reset by each keystroke, same as the 1-second case), so a pause between digits doesn’t blank the screen mid-entry. Otherwise this is skipped and the lock behaves as it always has. Ordinary touch, pointer, and keyboard input are ignored while blanked rather than treated as a wake, since a phone in a pocket brushes its screen constantly.

Two independent triggers toggle the blank state (off if on, on if off, responding within one event-loop iteration): a SIGUSR1 sent to the --worker process, useful for scripted/SSH testing; and the physical power button (XF86PowerOff), handled directly as an ordinary keyboard event in press_key. The latter needs no compositor keybind — while a session is locked, a spec-compliant compositor forwards physical keys straight to the lock client instead of consuming them for its own keybinds (0xin’s own handle_keybinding does this via a server.locked check specifically so its keybinds can’t be used to bypass a lock), so patin-lock already receives XF86PowerOff as ordinary input while locked and can act on it itself.

The public process supervises an internal worker. A successful PAM result makes the worker send the protocol unlock request and exit successfully. A crash is restarted after a delay while the compositor’s session-lock protocol remains fail-closed; configuration/protocol errors are terminal instead of entering a restart loop. This is generic Wayland/PAM behavior and contains no 0xin or FP5 branch.

Internal UI core

Logical Point, Size, and Rect types are shared by layout, hit-testing, draw commands, and damage. Row and column distribute fixed and weighted fill lengths along one axis; stack assigns the same bounds to layered children. When fixed children cannot fit, they shrink proportionally instead of generating negative or overflowing rectangles.

DrawCommand::RoundedFill { bounds, color, radius } sits alongside the plain Fill variant for consumers that want rounded corners (button-like elements, key backgrounds). CpuRenderer builds the rounded rectangle as a tiny-skia path from four cubic-bezier corners rather than relying on a library-provided rounded-rect helper, since tiny-skia 0.12 doesn’t have one; the radius is clamped to half the smaller side so it degrades to a normal rectangle instead of self-intersecting on very small or very radius-heavy bounds.

examples/demo_bar/scene.rs is a retained test composition:

Row
├── Clock (fixed left preference)
├── Optional volume fixture (fixed left preference)
├── Empty center (weighted fill)
├── Optional wifi, wired, and cellular fixtures (fixed right preferences)
└── Optional battery fixture (fixed right edge preference)

The row sits inside a logical horizontal inset, so its two end components do not touch an output edge at any output scale. Fixed-width components grow inward from both edges while a flexible spacer consumes the center. This keeps content away from centered output obstructions without naming or detecting a specific device. The example owns component state and styling. Optional battery, volume, and individual network-transport fixtures join its row only when their providers return values. It emits a small command list and records logical damaged rectangles when state changes. Resize and scale changes damage the full bar; clock and status-fixture changes damage only their component bounds. The Wayland boundary converts those rectangles to outward-rounded physical buffer coordinates. The bar has no interactive element right now — Shell::activate_at is a no-op — so pointer and touch presses reach it without visibly changing anything.

Battery, volume, brightness, and network themselves are toolkit-level provider crates (see “Service adapters” below), not example implementation details — only their composition into one StatusSnapshot in examples/demo_bar/services.rs is. The Chrono dependency is a genuine example-only detail. None of this is exported by src/lib.rs or ever constructed by platform::run.

Service adapters

patin::service::Provider is a minimal, dependency-free trait: poll(&mut self) -> Self::Snapshot. It is the only thing the core crate contributes to service integration. Construction is left to each adapter, since opening a D-Bus connection or similar can fail in ways only that adapter understands.

Concrete adapters live in their own workspace crates under crates/, never in src/, so their dependencies (zbus, and later whatever a media adapter needs) never reach a consumer that only wants the toolkit. Four exist so far, named by domain rather than mechanism except where one real service owns the domain outright:

  • crates/patin-service-upower polls UPower’s DisplayDevice — the synthetic aggregate battery device UPower maintains for shells — over zbus’s blocking API.
  • crates/patin-service-network polls NetworkManager’s active connections plus access-point strength for wifi and wired state. It independently reads registered modem signal from ModemManager, allowing wifi and cellular fields to be populated simultaneously. Missing transports remain None/false inside a real snapshot; an unavailable system bus returns None.
  • crates/patin-service-volume and crates/patin-service-brightness have no equivalent standard D-Bus interface to poll (noted in Status Providers), so they shell out to wpctl/pactl and read /sys/class/backlight respectively.

All four degrade their Provider::poll result when their underlying service or file isn’t reachable, the same failure behavior the demo’s status fixtures already had before they moved into these crates.

All four adapters are intentionally poll-based, reusing the same Shell::update tick the demo already had. A push-only service such as notifications will need a way to wake the platform event loop from a background thread between ticks; that plumbing does not exist yet and is scoped to whichever future stage first needs it.

Deliberate boundaries

Rendering is kept behind a small interface so a GPU backend can be added if measurements justify it. Patin exposes only shell-focused primitives and does not build abstractions for hypothetical application GUI use.

Core behavior must not branch on hardware models, connector names, fixed resolutions, compositor brands, or assumed scales. Outputs, transforms, input capabilities, and protocol support come from Wayland at runtime. Different shell compositions are selected explicitly and built from the same platform, rendering, UI, component, and service modules.

Patin reuses focused libraries for standards-heavy work. It does not reimplement Wayland protocol machinery, font shaping, D-Bus, or complex system-service protocols merely to remain “from scratch.”

Environment and Toolchain

Patin is ordinary Linux userspace software for compatible Wayland environments. Current verification covers x86_64 Arch Linux and aarch64 postmarketOS, but no distribution, architecture, device, or compositor defines the core design.

Rust

The repository pins the development toolchain to Rust 1.97.1 with the minimal rustup profile plus rustfmt and Clippy. Cargo.toml declares 1.97 as the minimum supported Rust release, which includes Alpine Rust 1.97.0. A rustup-based checkout selects the exact development pin through rust-toolchain.toml.

rustc --version
cargo --version
cargo build
cargo run --example demo_bar

The first surface uses smithay-client-toolkit 0.21.1 with default features disabled and its calloop and xkbcommon features enabled. SCTK supplies the Wayland client bindings, layer-shell protocol bindings, shared-memory slot pool, surface/output/seat tracking, and Calloop event source. Patin binds pointer and touch capabilities through SCTK. The keyboard support is used by the optional patin-lock consumer; the bar still deliberately requests no keyboard interactivity.

The rendering stage adds:

  • tiny-skia 0.12.0 for CPU raster primitives;
  • cosmic-text 0.19.0 with fontconfig and Swash for system-font discovery, shaping, layout, fallback, and glyph rasterization;
  • Chrono 0.4.45 for local wall-clock time.

Runtime requires a Wayland compositor that advertises wl_compositor, wl_shm, and wlr-layer-shell-unstable-v1. The client uses the pure Rust Wayland backend, so this stage does not require linking against the system libwayland-client. wp_fractional_scale_manager_v1 and wp_viewporter are optional; compositors without them use the integer wl_output scale path.

The UI core adds no external dependency. Its logical geometry, layout, styling, scene commands, hit-testing, and damage tracking are internal Rust modules built around demonstrated shell components.

The optional patin-launcher composition uses freedesktop-desktop-entry 0.8.1 with default features disabled. Desktop-entry localization, visibility fields, XDG search paths, and Exec field codes are a standard with enough edge cases that a narrow parser is safer and cheaper than reimplementing them. The dependency belongs to the launcher crate only; the Patin toolkit and other consumers do not inherit it.

The launcher additionally uses image 0.25.10 with only its PNG feature and resvg 0.47.0 with all default features disabled. They decode PNG and render SVG application icons without enabling resvg’s text, system-font, or raster-image features. freedesktop-icons 0.4.0 resolves their paths by reading the selected theme’s indexes, declared sizes, inheritance, XDG roots, hicolor fallback, and pixmaps. Some installations contain valid files omitted from their theme metadata, so the launcher applies a bounded exact-name search of only the selected theme and hicolor trees after standards lookup fails. These fallback roots preserve environment-provided priority but always include the canonical /usr/local/share, /usr/share, and system Flatpak export roots so an incomplete remote-session XDG_DATA_DIRS cannot hide system icons. These dependencies remain launcher-only; entries without a usable icon receive a neutral fallback.

The optional patin-session composition adds no dependency. It launches systemctl reboot and systemctl poweroff as separate program/argument values. A compositor integration may add its logout row with PATIN_SESSION_LOGOUT_PROGRAM, optional PATIN_SESSION_LOGOUT_ARGUMENT, and optional PATIN_SESSION_LOGOUT_LABEL; Patin never evaluates them through a shell.

The toolkit does not require a battery, backlight, or audio command. The demo optionally uses /sys/class/power_supply, /sys/class/backlight, wpctl, and pactl; missing providers merely remove those demo labels.

echo "$WAYLAND_DISPLAY"
cargo run --example demo_bar

Patin reports a clear error and exits unsuccessfully when no compositor can be found or a required global is unavailable.

Lock-screen requirements

Building patin-lock requires the system PAM and xkbcommon development packages in addition to the Rust toolchain (linux-pam-dev and libxkbcommon-dev on postmarketOS/Alpine; package names vary elsewhere). xkbcommon is the keyboard-state library used by SCTK’s keyboard support. Runtime requires a compositor that advertises ext-session-lock-v1 and a matching /etc/pam.d/patin-lock policy.

./scripts/install-lock-user.sh
sudo install -m 0644 data/pam/patin-lock.alpine /etc/pam.d/patin-lock
patin-lock

Use the .arch or .debian example instead on those PAM stacks. PAM policy is system security configuration and is therefore never installed implicitly by the user installer. The program checks that the policy exists before requesting the Wayland lock, avoiding a lock screen with no configured authentication route.

Remote Wayland session testing

An SSH login normally does not inherit the graphical session environment. Discover the target user’s runtime directory and active Wayland socket, then set them explicitly:

cd ~/Projects/patin
cargo build --release --locked --example demo_bar

env -u LD_LIBRARY_PATH \
  XDG_RUNTIME_DIR="/run/user/$(id -u)" \
  WAYLAND_DISPLAY=wayland-0 \
  target/release/examples/demo_bar

Unsetting LD_LIBRARY_PATH prevents a shell client from loading 0xin’s private wlroots/sysroot libraries. Keep a separate recovery connection available while testing a standalone compositor:

ssh <host> 'pkill -TERM -x 0xin'

The current FP5 test checkout can be launched from its own terminal with:

env -u LD_LIBRARY_PATH \
  XDG_RUNTIME_DIR="/run/user/$(id -u)" \
  WAYLAND_DISPLAY=wayland-0 \
  /tmp/patin-fp5-test/target/release/examples/demo_bar

Because the checkout is under /tmp, rebuild or install the binary to a persistent user path after a reboot.

Installing the demo command

The repository includes an explicit user installer for the example:

./scripts/install-demo-user.sh
patin

It builds demo_bar in release mode and installs only that example executable as ~/.local/bin/patin. The library still defines no default shell binary. Ensure the login profile contains this conventional user binary directory:

PATH="$PATH:$HOME/.local/bin"

Frame submission and raw touch logs are disabled during normal operation. To debug rendering, scaling, damage, or contact delivery:

PATIN_TRACE=1 patin

Documentation

The book is built with mdBook 0.5.3 in CI and Pages automation.

cargo install mdbook --version 0.5.3 --locked
mdbook build

Generated Cargo output (target/) and book output (book/) are ignored.

Required checks

On Debian/Ubuntu, prepare a fresh build environment with the workspace’s native dependencies before running the checks:

sudo apt-get update
sudo apt-get install --yes libpam0g-dev libxkbcommon-dev

The GitHub Actions check job performs this setup explicitly. In particular, libxkbcommon-dev installs xkbcommon.pc, which SCTK’s build script locates through pkg-config; the runtime library by itself is not enough to compile the crate.

Every milestone runs:

cargo fmt --all -- --check
cargo test --all-targets
cargo clippy --all-targets --all-features -- -D warnings
mdbook build
git diff --check

Commands which require a compositor or hardware will be added to the corresponding stage rather than pretending they can be verified in foundation CI.

Status Providers

Battery, volume, brightness, and network are each an optional, opt-in toolkit crate implementing patin::service::Provider (see Architecture), not code inside the patin library or a demo-only fixture. examples/demo_bar/services.rs composes battery, volume, and network into one StatusSnapshot for its row layout — that composition, and the row’s dynamic membership/damage behavior, remains the demo’s own job. A missing provider does not prevent the example from starting.

Battery

crates/patin-service-upower’s BatteryProvider (see Stage 6a) reads UPower’s synthetic DisplayDevice over D-Bus — the aggregate device UPower maintains specifically for status bars — via zbus’s blocking API, fetching the Percentage and State properties. It does not depend on battery names such as BAT0 or on a hardware model. A missing system bus or UPower service returns None.

The demo renders the percentage as the fill level of a battery outline. Low battery uses a warning color and charging uses the Patin accent; no percentage or name is drawn.

Volume

Linux audio systems do not expose one universal standard D-Bus volume interface, so crates/patin-service-volume’s VolumeProvider (see Stage 6b) shells out instead:

  1. wpctl get-volume @DEFAULT_AUDIO_SINK@;
  2. pactl get-sink-volume @DEFAULT_SINK@ plus get-sink-mute.

This supports a native PipeWire default sink and the common PulseAudio compatibility service. Failure of both commands returns None. The demo maps the percentage to zero through three sound bars; mute replaces them with a warning-colored strike.

Brightness

There is no portable D-Bus property to read the current backlight level (systemd-logind only exposes a SetBrightness method, not a readable one), so crates/patin-service-brightness’s BacklightProvider reads Linux’s documented /sys/class/backlight ABI directly: it discovers entries, reads brightness and max_brightness, and returns a percentage. It does not assume a driver or panel name. A missing or invalid backlight entry returns None. The adapter remains available to other compositions, but the current demo bar deliberately does not instantiate it.

Network

crates/patin-service-network’s NetworkProvider (see Stage 6e) reads every active NetworkManager connection over D-Bus. A wifi connection additionally walks ActiveConnection.DevicesDevice.Wireless.ActiveAccessPointAccessPoint.Strength for a signal percentage; ethernet sets the independent wired field.

The same provider discovers modem objects through ModemManager’s standard ObjectManager interface. A registered modem contributes its independent SignalQuality percentage. This represents simultaneous wifi and SIM service without treating VPN or loopback connections as physical transports.

The demo draws a wifi fan, a linked-node icon for wired, and cellular strength bars. Only active/registered transport icons receive slots.

Polling

The demo’s Shell::update polls its three providers once per platform update. Unchanged values produce no redraw; changed values damage only their component. A provider’s snapshot appearing or disappearing changes row membership and damages the full bar.

examples/demo_bar/services.rs preserves the adapters’ structured snapshot types instead of formatting strings. examples/demo_bar/scene.rs owns all icon choices and builds them from existing Fill and RoundedFill commands, so neither the toolkit nor service crates prescribe a visual style or require an icon font.

All four adapters are poll-based today, reusing the same once-per-second tick. A future push-only service (notifications, media) will need a way to wake the platform event loop from a background thread between ticks — that plumbing does not exist yet.

Roadmap

Patin grows in visible, testable stages. Visible shell features are examples and templates that validate toolkit APIs; they are not automatically instantiated by the library:

  1. Foundation — establish the pinned Rust project, documentation, checks, license, and publishing automation.
  2. First surface — connect as a Wayland client, create a top layer-shell surface, reserve its exclusive zone, and fill it with a solid color.
  3. Rendering — introduce drawing primitives and a correctly scaled clock using tiny-skia and cosmic-text.
  4. Input — handle pointer and multitouch input; tapping a visible target changes bar state without affecting application focus.
  5. UI core — add internal row, column, and stack layout, styling, hit-testing, damage tracking, and reusable components.
  6. Service adapters — design optional provider interfaces from demonstrated battery, network, audio, notification, and media examples.
  7. Session lock — provide an independently launched, multi-output ext-session-lock-v1 composition with physical/touch password entry and PAM authentication.
  8. Composition templates — exercise phone navigation, launchers, quick settings, notifications, and keyboard control without making them defaults.
  9. Compositor integration — add a replaceable adapter for 0xin workspace state and commands.

CPU rendering comes first. A GPU renderer is considered only after measurement shows a real performance need.

Stage 1 — Foundation

Concept

A reproducible foundation makes later graphics work easier to understand. The toolchain, checks, documentation, and project rules are fixed before Wayland introduces protocol state and platform dependencies.

This stage deliberately does not open a display or create a surface. The patin binary is a valid, dependency-free Rust program that exits successfully without output. Stage 2 will replace that temporary behavior with the first native layer-shell surface.

What changed

  • Cargo.toml, Cargo.lock, rust-toolchain.toml, and src/main.rs define the private Rust 2024 binary and pinned toolchain.
  • AGENTS.md makes documentation and real verification part of every code change.
  • book.toml, docs/, and docs/SUMMARY.md establish the architecture, environment, roadmap, and stage record.
  • GitHub Actions check the project and publish this book.
  • README.md, .gitignore, and LICENSE establish the public project entry point and repository policy.

main is intentionally empty. Adding fake runtime structure now would create interfaces without a real Wayland use case, contrary to the project’s internal-primitives-first rule.

Verification

The following checks must pass before this stage is complete:

cargo fmt --all -- --check
cargo test --all-targets
cargo clippy --all-targets --all-features -- -D warnings
mdbook build
git diff --check

Actual result

Verified on 28 July 2026 with Rust 1.97.1 and mdBook 0.5.3:

  • cargo fmt --all -- --check completed without formatting differences.
  • cargo test --all-targets compiled Patin and passed; there are intentionally no behavior tests before the first behavior exists.
  • cargo clippy --all-targets --all-features -- -D warnings completed without warnings.
  • mdbook build generated the HTML book successfully.
  • git diff --check reported no whitespace errors.

Stage 2 — First Surface

Concept

A Wayland surface is only pixel storage until a shell protocol gives it a role. Patin uses wlr-layer-shell-unstable-v1, which lets desktop components select a z-order layer, anchor themselves to output edges, and reserve space that normal application windows should not occupy.

Patin creates one surface on the top layer, anchors it to the top, left, and right edges, requests a logical height of 32 pixels, and sets a matching exclusive zone. Keyboard interactivity is explicitly disabled, so the bar cannot take keyboard focus from applications.

The initial empty commit asks the compositor to configure the surface. Patin does not guess the output width: it waits for that configure event, allocates an ARGB8888 wl_shm buffer of the returned size, fills every pixel purple, damages the full buffer, attaches it, and commits the visible frame.

What changed

  • Cargo.toml pins smithay-client-toolkit 0.21.1 with only Calloop support. SCTK provides the protocol bindings, registry handling, surface/output state, and safe shared-memory slot pool.
  • src/main.rs connects to Wayland, binds required globals, configures the layer surface, dispatches events with Calloop, and submits the first buffer.
  • src/render.rs contains the compositor-independent solid ARGB fill and its unit tests.
  • The README and architecture/environment chapters now describe actual first surface behavior and runtime requirements.

The first surface targets the compositor-selected default output. Per-output bars, scale-aware buffer sizing, output hotplug behavior, and buffer reuse are deliberately deferred to the stages that can demonstrate them properly.

Important functions

  • run owns startup: connection, registry enumeration, global binding, layer configuration, state construction, and the Calloop dispatch loop.
  • Patin::configure accepts the compositor’s first size and initiates drawing. It remembers that size and ignores identical configure events, preventing a configure/commit feedback loop while still redrawing after a real resize.
  • Patin::draw creates and attaches the shared-memory buffer.
  • fill_solid_argb writes one little-endian ARGB value into every pixel and is independent of Wayland.

Verification

Verified on 28 July 2026 with Rust 1.97.1, SCTK 0.21.1, and Hyprland:

cargo fmt --all -- --check
cargo test --all-targets
cargo clippy --all-targets --all-features -- -D warnings
cargo run
hyprctl layers
mdbook build
git diff --check

Two renderer tests passed. The live run reported:

patin: connected; waiting for the compositor to configure the bar
patin: rendered 1920x32 top bar with a 32px exclusive zone

The host compositor independently listed a 1920x32 surface in layer level 2 (top) with namespace patin. Other shell panels were active during this test, so Hyprland placed Patin below their already-reserved top area rather than at output coordinate zero.

The integration check then launched current local 0xin nested at 1280x720 with Patin as its child client:

0xin: socket ready — WAYLAND_DISPLAY=wayland-0
0xin: spawned client `/home/vdzee/proj/patin/target/debug/patin`
patin: connected; waiting for the compositor to configure the bar
patin: rendered 1280x32 top bar with a 32px exclusive zone

The purple bar was visible in the nested 0xin output. Stopping 0xin shut the compositor down cleanly after the client test.

Aarch64 phone verification

Patin was then copied to a temporary build directory on an aarch64 phone running postmarketOS edge and built natively:

rustc 1.97.0 (Alpine Linux)
cargo build --release --locked
Finished `release` profile [optimized] target(s) in 54.59s

With standalone 0xin already running, Patin connected over /run/user/10000/wayland-0 while a second SSH connection kept recovery available. The first run exposed repeated identical configure events from the compositor. Remembering the last configured size fixed the resulting configure/commit feedback loop.

The corrected build stayed alive alongside 0xin and rendered exactly once:

patin: connected; waiting for the compositor to configure the bar
patin: rendered 509x32 top bar with a 32px exclusive zone

The 509 logical-pixel width is consistent with the test output’s 1224-pixel portrait mode at scale 2.4. This solid-color stage verifies connection, layer role, logical geometry, and process isolation. It does not yet verify scale-aware buffer allocation or text sharpness. Patin was stopped with Ctrl-C after the test, and an independent SSH check confirmed that 0xin remained running.

Stage 3 — Scale-aware Rendering

Concept

Wayland configures surfaces in logical coordinates. A 32-pixel logical bar may need a 32, 48, or 77-pixel physical buffer depending on output scale. Drawing text directly at the logical size and asking the compositor to enlarge it makes glyphs blurry.

Patin requests wp_fractional_scale_v1 and wp_viewport when available. The preferred scale is expressed in 120ths: 120 is 1× and 180 is 1.5×. Physical dimensions are the logical dimensions multiplied by that scale and rounded upward. The viewport destination remains the logical size and wl_surface buffer scale stays 1, as required by the fractional-scale protocol. Without both optional protocols, Patin uses the integer wl_output scale.

Rendering boundary

CpuRenderer is an internal backend with no Wayland knowledge. It:

  1. creates a physical tiny-skia pixmap;
  2. fills the background and draws a two-logical-pixel accent rectangle;
  3. asks cosmic-text to shape and rasterize a right-aligned monospace clock;
  4. scales font metrics and padding with the physical scale;
  5. converts tiny-skia RGBA storage to Wayland’s little-endian ARGB8888 canvas.

Shared-memory pool slots may contain alignment padding after the visible pixel payload. The conversion therefore writes exactly the rendered bytes into any slot large enough to hold them and leaves trailing allocation bytes untouched.

The font system and Swash glyph cache live for the process lifetime. This keeps font discovery and glyph rasterization state out of the frame loop.

Event and frame flow

A Calloop timer checks local time once per second, but requests a redraw only when the displayed HH:MM string changes. Configure and scale changes also request redraws. Each submitted buffer requests a Wayland frame callback; if state changes while a frame is pending, one redraw is retained and submitted after the callback. The SCTK slot pool can recycle shared-memory storage after the compositor releases its buffers.

Important functions

  • Scale::physical converts logical lengths using protocol-native 120ths and ceiling division.
  • CpuRenderer::render_bar is the internal CPU renderer entry point.
  • CpuRenderer::draw_clock owns cosmic-text layout and tiny-skia glyph compositing.
  • Patin::request_redraw and Patin::frame coalesce updates around compositor frame callbacks.
  • The fractional-scale dispatch handler updates scale without depending on an output, device, or compositor name.

Verification

Verified on 29 July 2026 with Rust 1.97.1:

cargo fmt --all -- --check
cargo test --all-targets
cargo clippy --all-targets --all-features -- -D warnings
cargo run
grim /tmp/patin-stage3.png
mdbook build
git diff --check

Three unit tests passed: fractional physical-size rounding, RGBA-to-ARGB conversion into a padded shared-memory slot, and zero-padded clock formatting. On the active 1× output, Patin rendered a 1920x32 buffer for a 1920x32 logical bar. The screenshot confirmed that the clock was visible, right-aligned, vertically centered, and separated from the desktop by the accent.

The timer crossed two minute boundaries during the live test and produced exactly one logged render for each new minute.

Fractional-scale verification

A nested compositor output was configured to 1.5×. Patin first submitted its 1× fallback while protocol events were arriving, then reacted to the preferred scale:

patin: rendered 853x32 buffer for 853x32 logical bar
patin: rendered 1280x48 buffer for 853x32 logical bar

The second dimensions are ceil(853 × 1.5) by 32 × 1.5. A raw screencopy of the nested 1280x720 output confirmed the clock was sharp and correctly right-aligned in the scaled bar.

The same source was then built natively on an aarch64 Wayland system and launched against its running compositor. It received a 2.4× preferred scale:

patin: rendered 509x32 buffer for 509x32 logical bar
patin: rendered 1222x77 buffer for 509x32 logical bar

This test exposed the valid shared-memory slot padding described above. After adding the regression test and general renderer fix, Patin stayed running at the fractional scale. Terminating Patin left both the compositor process and its Wayland socket alive.

Stage 4 — Pointer and Touch Input

Concept

Wayland seats advertise capabilities rather than hardware identities. A seat can gain or lose a pointer or touchscreen while Patin is running. Patin binds each advertised pointer and touch capability and releases the corresponding protocol object when it disappears. No device, connector, or compositor name is involved.

Pointer and touch positions arrive in surface-local logical coordinates. The toggle target is therefore a logical rectangle shared by both input paths; it does not change when the renderer creates a larger physical buffer for a fractionally scaled output.

The bar retains KeyboardInteractivity::None. Pointer or touch interaction can change Patin state without asking the compositor to move keyboard focus away from an application.

Visible behavior

The leftmost 180 logical pixels form a visible SHELL OFF target. A primary pointer press or a touch-down inside it toggles the state. The target becomes green and reads SHELL ON; another activation restores the initial state. Every touch-down is processed independently, including contacts delivered together in a multitouch frame.

The toggle is deliberately local demonstration state. It proves input, hit-testing, component state, and redraw flow without inventing a shell action before the UI-core stage.

Important functions

  • input::Rect::contains performs the half-open logical-coordinate hit test.
  • input::toggle_target defines target geometry independently of rendering.
  • Patin::activate_at applies the shared hit test, changes state, and requests a redraw.
  • SeatHandler::new_capability and remove_capability follow runtime pointer and touch availability for every seat.
  • PointerHandler::pointer_frame accepts only primary-button presses on Patin’s layer surface.
  • TouchHandler::down, up, and cancel track overlapping contacts by touch object and contact ID.
  • CpuRenderer::draw_toggle draws the visible state at the current physical scale.

Verification

Verified on 29 July 2026 with Rust 1.97.1:

cargo fmt --all -- --check
cargo test --all-targets
cargo clippy --all-targets --all-features -- -D warnings
cargo run
grim /tmp/patin-stage4-initial.png
mdbook build
git diff --check

The local screenshot showed the SHELL OFF target at the left, the clock at the right, and the bar’s accent and exclusive zone unchanged.

The same source was built natively and run against the FP5’s active Wayland compositor. The compositor supplied a 2.4× preferred scale:

patin: rendered 509x32 buffer for 509x32 logical bar
patin: rendered 1222x77 buffer for 509x32 logical bar
patin: toggle activated; state is on
patin: rendered 1222x77 buffer for 509x32 logical bar
patin: toggle activated; state is off
patin: rendered 1222x77 buffer for 509x32 logical bar

Two real touchscreen activations changed the visible state and redrew the scaled buffer. The initial 112-logical-pixel target was too narrow for a comfortable two-finger test, so it was widened generically to 180 logical pixels and active-contact logging was added.

A follow-up simultaneous two-finger tap then repeatedly reported distinct contact IDs with active contacts: 2. On the final attempts both contacts activated the target before either contact produced an up event, toggling off → on → off with a redraw after each activation. This confirms genuine overlapping multitouch delivery rather than two fast sequential taps. Stopping Patin left the compositor process and Wayland socket alive.

Stage 5 — Internal UI Core

Concept

Previous stages drew and hit-tested the toggle and clock directly from platform and renderer code. That works for one bar, but it cannot scale into launchers, quick settings, or alternative compositions without duplicating geometry.

Stage 5 introduces a small retained UI scene. It is deliberately an internal shell implementation, not a public general-purpose toolkit.

Geometry and layout

Point, Size, and Rect use logical floating-point coordinates. The same rectangles drive layout, drawing, hit-testing, and damage, avoiding separate scale-dependent definitions.

Row and column accept fixed and weighted-fill lengths. Stack gives multiple children the same bounds for overlays. Gaps are reserved first; remaining space is assigned to fill children. On a very narrow surface, fixed children shrink proportionally rather than overflowing or receiving negative sizes.

The current bar is a row:

Toggle (180) | Spacer (fill) | [Battery] | [Volume] | [Brightness] | Clock (72)

Bracketed status components participate only when their providers are available.

Scene and styling

The demo DemoBar owns the clock string, toggle state, component bounds, and its style. It generates renderer-neutral Fill and Text commands. The CPU renderer maps logical bounds to physical pixels and executes those commands with tiny-skia and cosmic-text. It no longer knows what a toggle, clock, or bar layout is.

The scene also owns hit-testing. Pointer and touch handlers ask the scene for an action at a logical position instead of testing a hard-coded rectangle.

Damage

State mutations record logical damage:

  • resize and output-scale changes invalidate the full scene;
  • toggling invalidates only the toggle bounds;
  • a new minute invalidates only the clock bounds.

Before attaching the next buffer, the platform converts every logical damage rectangle to physical coordinates, flooring its origin and ceiling its far edge. Outward rounding ensures fractional pixels are never omitted.

The current shared-memory backend still draws a complete fresh buffer. Damage describes which parts differ from the previously committed surface content and is now ready for later buffer reuse and partial raster work.

Important functions

  • row, column, stack, and linear_layout implement internal layout.
  • Rect::contains and Rect::inset provide shared geometry operations.
  • DemoBar::resize computes the example component bounds.
  • DemoBar::action_at maps an input position to an example action.
  • DemoBar::activate_at, update, and damage_all mutate demo state and record appropriate damage through the toolkit’s Shell trait.
  • DemoBar::commands builds the renderer-neutral example scene.
  • CpuRenderer::render_bar executes generic draw commands.
  • Patin::draw converts logical damage to physical Wayland buffer damage.

Verification

Verified on 29 July 2026 with Rust 1.97.1:

cargo fmt --all -- --check
cargo test --all-targets
cargo clippy --all-targets --all-features -- -D warnings
cargo run --example demo_bar
grim /tmp/patin-stage5.png
mdbook build
git diff --check

After the toolkit split, five library tests cover rendering and generic layout. Four demo-only tests cover clock formatting, volume parsing, brightness formatting, and example hit-testing/damage.

On the local 3440x32 logical output, the screenshot confirmed the visible layout was preserved. Real pointer presses toggled the state in both directions and each redraw reported one damaged component region.

The unchanged demo consumer was built natively and launched on the FP5. The scene first rendered at the integer fallback and then at the compositor’s 2.4× preferred scale:

patin: rendered 509x32 buffer for 509x32 logical bar (1 damaged region)
patin: rendered 1222x77 buffer for 509x32 logical bar (1 damaged region)

Repeated single-touch and overlapping two-finger input toggled the scene-generated target correctly. Every state change reported one damage region. Stopping Patin left the compositor and Wayland socket alive.

Demo status follow-up

The same example scene was used for optional battery, volume, and brightness fixtures. These are not Patin library components. On the laptop, a screenshot showed BAT 100%+ and VOL MUTE. On the FP5, the demo reported and rendered:

demo_bar: status providers: battery=BAT 55%+, volume=VOL 65%

The FP5 had no native PipeWire default sink at test time, so the volume adapter correctly fell back to its PulseAudio-compatible default sink. No device name or hardware branch was added.

Stage 5b — Toolkit and Example Boundary

Why this refactor was necessary

The first visible stages placed the bar composition and its status providers in the main crate path. That made useful demonstrations, but it incorrectly made one shell implementation look like Patin itself.

Patin is now a library. Visible compositions are examples or downstream projects that consume it.

Library

src/lib.rs exports three focused modules:

  • platform owns Wayland connection, layer surfaces, seats, pointer/touch routing, scaling, shared-memory buffers, frame callbacks, and physical damage;
  • render executes renderer-neutral fill and text commands on the CPU backend;
  • ui supplies logical geometry, row/column/stack layout, styling data types, draw commands, and hit-testing operations.

Consumers implement platform::Shell. The runtime asks that implementation to resize, update, handle an activation position, produce commands, return damage, and invalidate everything after scale changes.

Consumers also provide LayerConfig. Patin does not assume a top bar: layer level, anchors, size, namespace, exclusive zone, and keyboard policy are all explicit.

Demo

examples/demo_bar.rs is an executable test consumer. Its supporting files under examples/demo_bar/ own:

  • clock and toggle state;
  • bar style and row composition;
  • battery and brightness sysfs polling;
  • wpctl and pactl volume polling.

None of these are exported by the library or instantiated by platform::run.

Run the fixture with:

cargo run --example demo_bar

For devices where the demo is used repeatedly, the explicit installer creates a short user command without changing the crate boundary:

./scripts/install-demo-user.sh
patin

The installed patin executable is a copy of the demo example under ~/.local/bin; it is not an automatically built toolkit binary.

Per-frame and raw-touch platform diagnostics are opt-in with PATIN_TRACE=1. Normal demo runs retain startup/provider and error messages without continuously printing frame submissions.

The installer was run on the FP5 and a fresh login shell resolved and launched the short command:

$ command -v patin
/home/sn3rt/.local/bin/patin
$ patin
demo_bar: status providers: battery=BAT 100%+, volume=VOL 3%, brightness=BRI 86%
patin: rendered 1222x77 buffer for 509x32 logical bar (1 damaged region)

Verification

Verified on 29 July 2026:

cargo fmt --all -- --check
cargo test --all-targets
cargo clippy --all-targets --all-features -- -D warnings
cargo run --example demo_bar
mdbook build
git diff --check

Five toolkit tests and four demo tests passed. The local example rendered at 3440x32. The unchanged example was then built natively on the FP5 with:

cargo build --release --locked --example demo_bar

After the interrupted laptop session was recovered, it reported BAT 98%+, VOL 3%, and BRI 54%, then rendered 509x32 logical at 1222x77 physical. Stopping the demo left the compositor and Wayland socket alive.

Stage 6a — UPower Battery Service Adapter

Why this stage exists

docs/status-services.md already noted that command/sysfs polling in the demo was “intentionally a test fixture, not the toolkit’s service architecture,” and that “optional reusable provider crates may be designed later from demonstrated consumer needs.” This stage makes that real for one service: battery.

It also settles how service adapters are packaged going forward. Rather than feature-gating D-Bus support inside the core patin crate, Patin became a Cargo workspace: each adapter is its own crate under crates/, so a consumer that wants only the toolkit never compiles zbus or any other adapter dependency.

Workspace conversion

The root Cargo.toml gained [workspace] and [workspace.package] sections alongside its existing [package] table; the root package remains an implicit workspace member, so src/, examples/, and Cargo.lock did not move. edition, rust-version, license, and publish are now inherited via .workspace = true on both crates to avoid drift.

cargo build/test/clippy only operate on the current directory’s package unless --workspace is passed, so .github/workflows/ci.yml and the README’s verification commands now pass --workspace to test and clippy (fmt --all already covered every member).

Core crate: patin::service

src/service.rs adds one trait, exported from src/lib.rs:

#![allow(unused)]
fn main() {
pub trait Provider {
    type Snapshot: Clone + PartialEq;
    fn poll(&mut self) -> Self::Snapshot;
}
}

Construction is deliberately left out of the trait: opening a D-Bus connection (or whatever a future adapter needs) can fail in ways specific to that adapter, so each one exposes its own fallible new().

New crate: patin-service-upower

crates/patin-service-upower implements Provider for BatteryProvider against UPower’s synthetic DisplayDevice — the aggregate device UPower maintains specifically for status bars and shells, which avoids reimplementing the sysfs version’s “pick the best battery” logic. It reads the Percentage and State D-Bus properties over zbus’s blocking API on the existing once-per-second Shell::update tick; no calloop or platform.rs changes were needed for this poll-based adapter.

zbus = "=5.18.0" is used with default-features = false and an explicit feature list (async-executor, async-fs, async-io, async-lock, async-process, async-task, blocking, blocking-api) — resolved by trial build against the crate’s actual feature graph, since blocking-api alone does not transitively pull the executor it needs. This set excludes tokio; zbus implements the D-Bus wire protocol itself, so no libdbus system package is required.

A missing system bus, missing UPower service, or missing device all degrade to None via .ok()? short-circuiting, the same failure philosophy as the demo’s sysfs and wpctl/pactl fixtures. This also means unit tests (run without a real system bus reachable, or with one reachable but no UPower registered on it) see None deterministically rather than failing.

Demo integration

examples/demo_bar/services.rs’s SystemStatus now holds a BatteryProvider field instead of a power_supply_root path; the sysfs read_battery function and its formatting are gone, replaced by format_battery over BatterySnapshot. SystemStatus::poll became &mut self (both call sites were already in &mut self contexts). Volume and brightness are unchanged. Root Cargo.toml depends on patin-service-upower under [dev-dependencies], since only the example uses it.

Verification

Verified on 29 July 2026:

$ cargo fmt --all -- --check
(no output)

$ cargo test --workspace --all-targets
running 5 tests (patin)
... all ok
running 4 tests (examples/demo_bar)
... all ok
running 1 test (patin-service-upower)
test tests::poll_without_a_system_bus_returns_none ... ok

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s), no warnings

$ git diff --check
(no output, exit 0)

cargo run --example demo_bar (timeout 5s) in this sandbox, which has a Wayland compositor and a session/system D-Bus socket but no upowerd registered:

demo_bar: status providers: battery=unavailable, volume=VOL MUTE, brightness=BRI 71%
patin: connected; waiting for the compositor to configure the bar

Confirmed independently with dbus-send --system ... org.freedesktop.UPower ... that the service is genuinely ServiceUnknown here, so unavailable is the adapter degrading correctly, not a bug.

$ mdbook build
 INFO Book building has started
 INFO Running the html backend
 INFO HTML book written to `/home/vdzee/proj/patin/book`

FP5 end-to-end confirmation

Verified on the FP5 the same day (postmarketOS edge, aarch64, upowerd genuinely active). The working tree was copied over with tar piped over SSH (no rsync on-device), built natively with cargo build --release --locked --example demo_bar (~4 minutes cold, fetching zbus and its transitive dependencies), then installed and launched via the existing scripts/install-demo-user.sh:

demo_bar: status providers: battery=BAT 69%, volume=VOL 8%, brightness=BRI 70%
patin: connected; waiting for the compositor to configure the bar

A real percentage from D-Bus/UPower, not unavailable — the adapter works end to end against the target device’s actual UPower.

Backgrounding it as a plain setsid nohup ... & SSH command was not enough to keep it alive: the phone’s PAM session tears down its whole cgroup on SSH disconnect regardless of setsid. It stayed running only once launched as a transient systemd user unit instead:

systemd-run --user --unit=patin-demo --collect \
  --setenv=XDG_RUNTIME_DIR=/run/user/10000 \
  --setenv=WAYLAND_DISPLAY=wayland-0 \
  -- /home/sn3rt/.local/bin/patin

Stage 6b — Volume and Brightness Service Adapters

Why this stage exists

Stage 6a proved the patin::service::Provider + opt-in-crate pattern with battery, which polls a real D-Bus service (UPower). Volume and brightness were the two demo fixtures still left as subprocess/sysfs code inline in examples/demo_bar/services.rs.

Neither has a real D-Bus service to key a crate name off the way UPower did for battery: Linux audio has no universal standard D-Bus volume interface, and systemd-logind only exposes a SetBrightness method, not a readable one. So this stage names by domain concept instead of mechanism — patin-service-volume and patin-service-brightness — and is otherwise a mechanical port: the existing, already-working subprocess/sysfs logic moved verbatim into two new crates implementing Provider, restructuring their return values from pre-formatted strings into typed snapshots (matching BatterySnapshot’s shape), with formatting left to the demo. No new external dependencies, no calloop changes — same poll-once-per-second model as battery.

New crate: patin-service-volume

Ports read_volume/read_wpctl_volume/read_pactl_volume/ parse_wpctl_volume from the demo, returning VolumeSnapshot { percentage, muted } instead of a pre-formatted string. The percentage is no longer discarded when muted — the demo’s formatter chooses to still show "VOL MUTE" for now, but the data is available. Depends only on patin (for Provider); Command handling is all std.

New crate: patin-service-brightness

Ports read_brightness/brightness_label/read_trimmed verbatim, returning BrightnessSnapshot { percentage }. Same no-external-dependency shape as the volume crate.

Demo integration

examples/demo_bar/services.rs is now composition-and-formatting only: SystemStatus holds all three providers (BatteryProvider, VolumeProvider, BacklightProvider); poll calls each .poll() and maps through format_battery/format_volume/format_brightness to rebuild the same "BAT n%[+]" / "VOL n%" / "VOL MUTE" / "BRI n%" strings scene.rs already expected — scene.rs itself is unchanged. Root Cargo.toml gained both crates under [workspace] members and [dev-dependencies].

Documentation

docs/status-services.md was retitled from “Demo Status Fixtures” to “Status Providers” and rewritten: none of the three are demo-only fixtures anymore, only their composition into StatusSnapshot is. README.md and docs/architecture.md were updated to list all three adapter crates and drop the now-inaccurate “provisional audio and brightness providers are used by the demo only” framing.

Verification

Verified on 30 July 2026:

$ cargo fmt --all -- --check
(no output)

$ cargo test --workspace --all-targets
10 tests across 4 crates, all passed:
  patin: 5
  examples/demo_bar: 2
  patin-service-brightness: 1 (computes_brightness_and_rejects_zero_maximum)
  patin-service-upower: 1 (poll_without_a_system_bus_returns_none)
  patin-service-volume: 1 (parses_wpctl_volume_and_mute_state)

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s), no warnings

$ cargo run --example demo_bar (timeout 5s, this sandbox: no upowerd)
demo_bar: status providers: battery=unavailable, volume=VOL MUTE, brightness=BRI 71%
patin: connected; waiting for the compositor to configure the bar

$ mdbook build
 INFO Book building has started
 INFO Running the html backend
 INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

FP5 end-to-end confirmation

Verified on the FP5 the same day, same round-trip as stage 6a: working tree copied over with tar piped over SSH, built natively (cargo build --release --locked --example demo_bar, ~6s — fast, since zbus and its transitive deps were already cached from stage 6a and neither new crate adds an external dependency), then installed and relaunched as a transient systemd user unit (plain backgrounded SSH processes don’t survive the PAM session tearing down, per stage 6a):

systemd-run --user --unit=patin-demo --collect \
  --setenv=XDG_RUNTIME_DIR=/run/user/10000 \
  --setenv=WAYLAND_DISPLAY=wayland-0 \
  -- /home/sn3rt/.local/bin/patin
demo_bar: status providers: battery=BAT 66%, volume=VOL 8%, brightness=BRI 40%
patin: connected; waiting for the compositor to configure the bar

All three readings are real, not unavailable/degraded — patin-service-volume and patin-service-brightness work end to end against the FP5’s real wpctl/sysfs, alongside the already-proven UPower battery adapter.

Stage 6c — Network Service Adapter

This chapter records the original single-primary-connection stage. The current simultaneous-transport snapshot is documented in Stage 6e.

Why this stage exists

Stage 6 had battery, volume, and brightness done. Network is next, and unlike volume/brightness it has a real, near-universal D-Bus service to key off — NetworkManager — same shape as UPower for battery.

Before designing anything, the actual property names and types were checked live against the FP5’s NetworkManager over D-Bus (busctl --system ...), since guessing D-Bus API details from memory is exactly what turned out wrong for zbus’s feature flags in stage 6a:

  • org.freedesktop.NetworkManager at /org/freedesktop/NetworkManager exposes PrimaryConnection (an object path, "/" when there is none) and PrimaryConnectionType (a string — "802-11-wireless" was the live value on the FP5’s wifi connection).
  • A wifi signal percentage takes a real walk: the ActiveConnection object’s Devices → that Device’s org.freedesktop.NetworkManager.Device.Wireless.ActiveAccessPoint → that AccessPoint’s Strength, a y (byte, 0–100) — confirmed 68 live, no scaling needed unlike UPower’s f64 percentage.
  • The FP5 also runs ModemManager (cellular capability exists), but pulling in cellular signal detail was scoped out of this stage, the same kind of cut stage 6b made for audio/brightness detail. Any primary connection type other than wifi/wired (including gsm/cdma) just reports as generically connected.

New crate: patin-service-network

Named by domain (network), not mechanism, since a future cellular addition via ModemManager would still belong under the same domain rather than forcing a rename.

#![allow(unused)]
fn main() {
pub enum NetworkSnapshot {
    Disconnected,
    Wired,
    Wifi { percentage: u8 },
    Other,
}
}

Provider::poll returns Option<NetworkSnapshot>, but unlike battery, None here means only “NetworkManager unreachable over D-Bus” — being reachable but disconnected is a real reading (Some(NetworkSnapshot::Disconnected)), not folded into None. This matches how BatterySnapshot always carries a real charging reading rather than conflating “no data” with “off”.

The wifi signal walk (wifi_strength) is a small private helper using ? short-circuiting across the three D-Bus hops; if any hop fails despite the connection type saying wireless (an edge case), it degrades to NetworkSnapshot::Other rather than panicking or failing the whole poll.

Cargo.toml reuses the exact same zbus = "=5.18.0" pin and feature list as patin-service-upower — already resolved in stage 6a, no new research needed. It compiled clean on the first try, which is what checking the live D-Bus API first was for.

Demo integration

examples/demo_bar/services.rs gained a fourth provider field and a format_network function ("NET 55%" for wifi, "NET ETH" for wired, "NET OFF" disconnected, "NET UP" otherwise). StatusSnapshot gained a network: Option<String> field.

examples/demo_bar/scene.rs needed the same mechanical extension the other three fields already had — a fourth optional row slot, joining only when Some, plus its damage-tracking branch in set_status and its Text draw command — the one part of this stage that touched scene.rs (6a/6b were additive only). Root Cargo.toml gained patin-service-network under [workspace] members and [dev-dependencies].

Verification

Verified on 30 July 2026:

$ cargo build -p patin-service-network
Finished, no errors (compiled clean on the first try)

$ cargo fmt --all -- --check
(no output after one auto-fix to a too-long line)

$ cargo test --workspace --all-targets
11 tests across 5 crates, all passed, including
  patin-service-network: 1 (poll_without_a_system_bus_returns_none)

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished, no warnings

$ cargo run --example demo_bar (timeout 5s, this sandbox: no NetworkManager)
demo_bar: status providers: battery=unavailable, volume=VOL MUTE, brightness=BRI 71%, network=unavailable
patin: connected; waiting for the compositor to configure the bar

Confirmed independently with busctl --system list | grep -i networkmanager that NetworkManager is genuinely absent here, so unavailable is the adapter degrading correctly.

$ mdbook build
 INFO Book building has started
 INFO Running the html backend
 INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

FP5 end-to-end confirmation

Same round-trip as 6a/6b: working tree copied over with tar piped over SSH, built natively (fast — zbus already cached), installed, and relaunched as a transient systemd user unit per the fp5-test-device memory:

demo_bar: status providers: battery=BAT 64%, volume=VOL 8%, brightness=BRI 40%, network=NET 69%
patin: connected; waiting for the compositor to configure the bar

NET 69% is close to the 68 read directly from the access point’s Strength property during the initial busctl probe (signal strength drifts slightly between reads) — the adapter reads the FP5’s real wifi connection correctly end to end.

Stage 6d — Vector Status Icons

Why this stage exists

The first demo bar proved its service adapters by printing values such as BAT 64%, VOL 8%, and NET 69%. Those labels were useful diagnostics, but they made a 32-logical-pixel shell bar feel like a test fixture rather than a compact status surface.

This stage changes only the demo composition. Patin still does not ship a bar or prescribe icons, and the optional service adapters still return reusable data rather than presentation.

Structured state through the scene

examples/demo_bar/services.rs::StatusSnapshot now retains BatterySnapshot, VolumeSnapshot, and NetworkSnapshot directly. Removing the demo’s formatting functions avoids discarding useful state such as charging, mute, and transport strengths before the scene renders it.

examples/demo_bar/scene.rs converts that state into compact icons:

  • battery fill represents charge, with warning and charging colors;
  • zero through three bars represent volume, with a mute strike;
  • concentric signal arcs represent wifi, linked nodes represent wired, and ascending bars represent cellular strength.

The helpers use only existing DrawCommand::Fill and DrawCommand::RoundedFill primitives. This makes the icons scale with the Wayland surface and avoids emoji rendering, private-use glyphs, bundled assets, or an icon-font dependency. The clock deliberately remains text.

The demo row keeps the textual clock and optional volume icon in fixed slots growing inward from the inset left edge. Active wifi, wired, cellular, and battery indicators use fixed slots growing inward from the inset right edge. A flexible spacer between the clusters keeps the output center empty, avoiding centered obstructions without branching on a hardware or compositor name. The 12-logical-pixel outer inset is scale independent: the Wayland backend applies the output scale later when it creates the buffer. A value change alters its icon commands and damages only that status slot.

Changed files and important functions

  • examples/demo_bar/services.rs preserves provider snapshots instead of converting them to labels.
  • examples/demo_bar/scene.rs renders battery_icon, volume_icon, wifi_icon, wired_icon, and cellular_icon, with shared centering and shape helpers plus state- and layout-regression tests.
  • README.md and docs/status-services.md describe the visible behavior and retain the toolkit/example boundary.
  • docs/SUMMARY.md links this chapter.

Verification

Verified on 31 July 2026:

$ cargo fmt --all -- --check
(no output, exit 0)

$ cargo test --workspace --all-targets
all passed

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished, no warnings

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

The automated demo test confirms that all four status components emit shape commands rather than text and that battery charging and volume mute produce different command sets. A live visual check on the phone test target remains to be recorded.

The inset end layout was verified on 1 August 2026:

$ cargo fmt --all -- --check
(no output, exit 0)

$ cargo test --workspace --all-targets
28 passed, 0 failed

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished, no warnings

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

The new regression test uses the phone’s 509-by-32 logical bar size and checks that the clock starts at the left inset, the battery slot ends at the right inset, and a 64-logical-pixel area around the output center contains no status slot.

The same revision was then built natively on the aarch64 phone test target with its existing xkbcommon development path:

$ cargo build --release --locked --example demo_bar
Finished `release` profile; produced target/release/examples/demo_bar

$ systemctl --user is-active patin-bar.service
active

The installed ~/.local/bin/patin remained active as a Wayland client after a fresh SSH connection. The transient user service was used only to preserve the live test process after SSH disconnected; normal session startup remains the documented exec_once = ~/.local/bin/patin compositor configuration.

Stage 6e — Simultaneous Network Transports

Why this stage exists

A single PrimaryConnection cannot describe a phone with registered SIM service and wifi at the same time. VPN and loopback connections also must not masquerade as physical signal indicators. This stage changes the optional network adapter to report transport capabilities independently.

Snapshot and service boundaries

NetworkSnapshot is now a struct with wifi: Option<u8>, cellular: Option<u8>, and wired: bool. NetworkManager supplies active wifi and ethernet state; the wifi device’s active access point supplies signal strength. ModemManager’s standard ObjectManager discovers modem objects, and a modem at least in the registered state supplies SignalQuality.

The two D-Bus services stay behind NetworkProvider. No device, interface, modem path, compositor, or hardware name is encoded. Missing transports remain absent fields in an available snapshot, while an unavailable system bus still returns None.

Demo composition

The demo no longer constructs BacklightProvider; that optional crate remains available to toolkit consumers. Its right cluster now grows inward as wifi, wired when present, cellular, and battery. Wifi uses concentric strength arcs, while cellular retains ascending strength bars. Both icons can coexist and the flexible center spacer remains clear.

Verification

The local regression suite covers an empty transport snapshot, unavailable system bus, simultaneous wifi/cellular layout, shape-only icons, and the center-clearance invariant. The live phone test target reported a registered LTE/5G modem with 57% recent signal through ModemManager while NetworkManager reported an independent wifi connection.

Verified on 1 August 2026:

$ cargo fmt --all -- --check
(no output, exit 0)

$ cargo test --workspace --all-targets
29 passed, 0 failed

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished, no warnings

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

The matching source files were copied to the aarch64 phone test tree and their SHA-256 hashes matched the local files. Its native locked release build completed in 11.50 seconds. After installing only the demo executable and restarting only its transient user service, the live provider reported:

NetworkSnapshot { wifi: Some(70), cellular: Some(59), wired: false }
$ systemctl --user is-active patin-bar.service
active

The running composition therefore exercised simultaneous NetworkManager wifi and ModemManager cellular signal without a compositor restart.

Stage 7 — Session Lock

Why this stage exists

A phone lock screen cannot depend on a separate on-screen keyboard: once the session is locked, ordinary application surfaces must not appear above it. The lock client therefore owns both the secure Wayland surfaces and its touch password keyboard. It remains a separate Patin composition rather than becoming an automatically constructed toolkit feature.

Protocol and lifecycle

patin-lock requests ext-session-lock-v1, creates a surface for every output, and follows output hotplug and seat capability changes at runtime. The compositor, not the client, enforces exclusivity: after acknowledging the lock, it must not reveal the session merely because the client dies.

The outer patin-lock process supervises a --worker child. A normal child exit means authentication succeeded and the child sent the protocol unlock request. A panic or signal causes a delayed restart; missing globals, missing PAM configuration, or a compositor refusal are terminal errors. --worker is an internal implementation detail.

Input, rendering, and authentication

Every output uses Patin’s existing shared-memory CPU renderer. The minimal scene contains the time, effective username, a masked password field, status text, and a four-row QWERTY/symbol keyboard. Touch and pointer hit tests use logical coordinates, while SCTK’s XKB support supplies decoded physical keyboard input.

The password is limited to 256 UTF-8 bytes. UI-owned password strings use zeroize; submission moves the secret to a PAM worker thread and immediately clears the UI copy. PAM’s patin-lock service performs both authentication and account checks. Authentication failure clears the submitted secret inside the worker and re-enables input.

Installation

Install the user binary:

./scripts/install-lock-user.sh

Then explicitly install the PAM policy matching the host. For the FP5 postmarketOS/Alpine reference target:

sudo apk add linux-pam-dev
sudo install -m 0644 data/pam/patin-lock.alpine /etc/pam.d/patin-lock
patin-lock

Arch and Debian policy examples live beside the Alpine file. The installer does not modify /etc, and the client checks for the policy before acquiring the lock.

Do not bind a hardware power button to this command until a live session has confirmed touch entry and successful unlock. Keep an SSH recovery connection available during the first test.

Verification

Verified on 30 July 2026:

$ cargo check -p patin-lock
Finished, no warnings

$ cargo test -p patin-lock
2 tests passed

$ cargo fmt --all -- --check
(no output, exit 0)

$ cargo test --workspace --all-targets
12 tests across 6 crates, all passed

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished, no warnings

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

FP5 protocol and touch-authentication results are recorded after the reference target has completed its live lock/unlock test. The first native release build reached the final linker step and confirmed the expected missing prerequisite:

$ cargo build --release --locked -p patin-lock
ld: cannot find -lpam
ld: cannot find -lpam_misc

Install linux-pam-dev, then repeat the build and live test.

Stage 7b — Selectable Keypad and Idle Blank/Wake

Why this stage exists

Stage 7 shipped patin-lock with a single fixed QWERTY/symbol keyboard and a display that stays on for as long as the session is locked. Two gaps followed from real use on the FP5: some accounts use a numeric PIN rather than an alphanumeric password, and an always-on lock screen wastes battery and is a privacy signal in itself (anyone can see the clock/username glowing in a pocket or on a table). Both are consumer-level lock-composition choices, not toolkit behavior, so both live entirely in crates/patin-lock.

Selectable keypad

ui::KeyboardMode (Full or Numeric) is chosen once, at startup, via --keypad=full|numeric (default full, so existing installs are unaffected), or PATIN_LOCK_KEYPAD=full|numeric as a persistent default matching the existing PATIN_TRACE env-var convention — an explicit --keypad= flag always wins over the env var. The supervisor forwards whichever --keypad= flag it was given to its --worker child exactly like it already does with --worker itself; the env var needs no forwarding since child processes inherit it automatically. Numeric renders a fixed 3x4 digit grid (1-9, backspace, 0, enter) instead of the QWERTY/symbol pages; Full is the unchanged stage 7 keyboard. Both modes feed the same LockUi::press/take_password path — PAM checks whatever the account’s real password is, so a numeric keypad only makes sense for accounts whose password is itself a PIN.

The numeric grid’s keys are noticeably smaller than the first version, with real gaps between them (including outer left/right margins) instead of a thin shared inset, computed from patin::ui::DrawCommand::RoundedFill — a small addition to the core toolkit alongside the existing plain Fill, since key-like backgrounds reasonably want rounded corners and no rounded-rect primitive existed before this. The QWERTY/symbol keyboard picks up the same rounded corners for free since both keyboards share the same key-drawing loop in LockUi::commands; only the numeric grid’s cell sizing changed.

Idle blank and power-button wake

patin-lock binds zwlr_output_power_manager_v1 if the compositor advertises it (logged and skipped otherwise — the lock still works, it just never blanks). After a period without a real key/touch/pointer press, it calls set_mode(Off) on every output’s power object and stops drawing; this is a real DPMS-off, not a rendered black frame. The idle threshold is 1 second before the display has ever been woken (App::ever_woken), and 5 seconds from the moment of any wake onward, reset by each keystroke exactly like the 1-second case. Two earlier passes were both too aggressive: an unconditional 1 second blanked the screen mid-entry on any pause longer than a second between digits, and switching to 5 seconds only once the password buffer already had a character in it still left just 1 second between waking the display and typing the first digit — ever_woken fixes that by starting the 5-second grace at the wake itself, not at the first keystroke. Ordinary touch, pointer, and keyboard input are ignored while blanked rather than treated as a wake — a phone in a pocket brushes its screen constantly, and waking on any of that would defeat the point of blanking it.

Two independent triggers toggle the blank state instead (off if currently on, back on if currently off), so a single press or signal always does the right thing and responds within roughly one event-loop tick:

  • A SIGUSR1 sent to the --worker process, checked once per dispatch loop iteration. Useful for scripted/SSH testing, e.g.:
    pkill -USR1 -f -- '--worker'
    
    (pkill -f matches on the full command line, so this only ever reaches the worker — its argv contains --worker, the supervisor’s does not.)
  • The physical power button (XF86PowerOff), handled directly in press_key as an ordinary keyboard event. This needed no compositor config at all, once we understood why an earlier attempt (a compositor keybind spawning the SIGUSR1 command above) didn’t work: 0xin’s handle_keybinding short-circuits with if server.locked { return false; } before checking any bind table, deliberately forwarding every key straight to the locked client instead of running its own keybinds — so keybinds can never be used to bypass a lock. That forwarding is exactly what delivers XF86PowerOff to patin-lock as a normal wl_keyboard event while locked, so it’s handled there directly instead of round-tripping through an external signal. Any compositor that forwards keys to the lock client the same way (which ext-session-lock-v1 more or less implies) gets this for free, with zero configuration.

Verification

Verified on 30 July 2026:

$ cargo fmt --all -- --check
(no output, exit 0)

$ cargo test --workspace --all-targets
all passed

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished, no warnings

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

Manual, on the FP5 (physical touch and the physical power button can’t be driven over SSH, so this needs hands on the device):

  • --keypad=full and --keypad=numeric both unlock correctly.
  • The display visibly powers off after 1 second idle.
  • ssh fp5 "pkill -USR1 -f -- '--worker'" sent directly to an already-locked worker wakes it and redraws the lock UI — confirmed live, which is what surfaced the server.locked keybind-gating behavior above (a compositor bind wired to the same command never fired on a physical power press, while the identical command sent directly over SSH worked every time).

Still to confirm live: the direct XF86_PowerOff keysym handling in press_key, and the 5-second entering-password threshold, are both new code added after the findings above and have not yet been exercised on the device (only the unconditional 1-second timeout and the SIGUSR1 path have).

Stage 7c — Adaptive Lock-Screen Input

Why this stage exists

The first lock keyboards divided all remaining vertical space between four rows. That made keys grow with the output instead of remaining recognisable controls: the numeric layout looked like a grid of large panels, and the full keyboard became especially tall. Rounded corners alone could not correct the underlying geometry.

This stage gives patin-lock intrinsic keyboard dimensions. The composition is still tested on a phone, but it contains no hardware or compositor checks. It derives every position from the output’s logical width and height, caps keyboard width on large outputs, and reduces key size when space is limited.

Visual bounds and touch bounds

Each internal KeyLayout contains two rectangles:

  • visual_bounds is the compact rounded shape that gets drawn.
  • hit_bounds is the logical region accepted by pointer and touch hit-testing.

Keeping those concepts separate lets the keyboard have visible gaps and a lighter silhouette without making it unnecessarily difficult to tap. keyboard_numeric builds a centered 3-by-4 group of 44–72 logical pixel squircles. keyboard_full builds four lower-screen rows at 44–52 logical pixels high and limits their combined width to 720 logical pixels. A shared adaptive bottom margin raises either keyboard by 11% of the output height, clamped to 48–112 logical pixels. Both functions preserve the established QWERTY, symbol, Shift, Space, Backspace, and submit behavior.

key_colors assigns visual roles without changing input semantics. Character keys use Patin’s violet surface color, modifiers are quieter, Backspace has a subtle warm tint, and the unlock key carries the accent color. Shift gets an active treatment, and every key is dimmed while PAM authentication is in progress. The submit key uses a compact checkmark rather than a word label, so it remains legible inside both the numeric squircle and the narrower full keyboard key.

Password-field state

LockUi::password_field_text keeps the hint separate from authentication status:

  • An empty, idle field displays Enter password.
  • The first entered character replaces the hint with masking bullets.
  • Submission immediately clears the UI-owned secret and hides the hint while PAM verifies it.
  • Verifying… and failure messages remain below the field.
  • Editing after a failure clears the stale failure message.

The password field itself is a layered rounded rectangle. The extra outer layer gives it a visible boundary using existing DrawCommand::RoundedFill primitives, so this composition does not require a new public toolkit API.

Changed files and important functions

  • crates/patin-lock/src/ui.rs owns the new KeyLayout, adaptive keyboard geometry, role colors, field presentation, hit-testing, and regression tests.
  • README.md describes the user-visible keyboard and placeholder behavior.
  • docs/environment.md names xkbcommon’s development files as a lock-build requirement.
  • docs/SUMMARY.md links this stage from the mdBook navigation.
  • This chapter records the design and verification result.

Verification

Verified on 31 July 2026:

$ cargo fmt --all -- --check
(no output, exit 0)

$ cargo test --workspace --all-targets
all passed

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished, no warnings

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

The automated layout tests cover 320×500, 509×1020, and 1920×1080 logical outputs, both keyboard modes, centering, output containment, maximum width, minimum row height, placeholder transitions, authentication status, and touch hit-testing.

The source was also copied to the existing aarch64 phone test checkout. Its system still had the xkbcommon runtime but no development symlink or xkbcommon.pc, so libxkbcommon-dev was unpacked into a temporary directory for the build rather than reinstalling any system or desktop packages:

$ PKG_CONFIG_PATH=/tmp/tmp.CamNaD/usr/lib/pkgconfig \
  RUSTFLAGS="-L native=/tmp/tmp.CamNaD/usr/lib" \
  cargo test -p patin-lock
6 passed

$ PKG_CONFIG_PATH=/tmp/tmp.CamNaD/usr/lib/pkgconfig \
  RUSTFLAGS="-L native=/tmp/tmp.CamNaD/usr/lib" \
  cargo build --release --locked -p patin-lock
Finished release profile

$ install -m 0755 target/release/patin-lock ~/.local/bin/patin-lock
(exit 0)

The updated binary is installed and resolves libxkbcommon.so.0 from the phone’s normal /lib. The visual and physical-touch check remains to be recorded because acquiring a session lock remotely without someone ready to unlock it is unsafe. Keep SSH recovery available and exercise both patin-lock and patin-lock --keypad=numeric; the live check must cover failed authentication, retry, successful unlock, and the physical power-button wake path.

Stage 8a — Touch Application Launcher

Why this stage exists

0xin deliberately maps its top-edge gesture to an external command. Its phone profile previously spawned Fuzzel on a downward swipe and killed it on an upward swipe. That boundary means Patin can provide a replacement composition without becoming mandatory, depending on 0xin, or moving application policy into the compositor.

This first launcher stage is a useful touch target rather than a general app launcher framework: it discovers applications, renders a compact floating list, scrolls by pointer wheel or touch drag, starts one on tap, and exits. Keyboard search remains follow-up work.

Desktop entries and process launch

patin-launcher uses freedesktop-desktop-entry 0.8.1 with its optional gettext feature disabled. apps::discover walks standard XDG application locations, keeps the first occurrence of each desktop ID, applies localized names and desktop visibility rules, skips hidden/non-application entries and missing TryExec programs, then sorts names for a stable list.

Application::launch uses the library’s desktop Exec parser instead of a shell. The resulting first argument is passed directly to Command and the remaining arguments remain distinct, so desktop field codes are handled without introducing shell interpolation. A declared working directory is honored. Successful spawn closes the launcher; failure remains visible in the overlay so the user can choose another app or dismiss it.

Floating layout, icons, scrolling, and lifecycle

The binary requests a full-output overlay-layer surface anchored on every edge, reserves no exclusive zone, and requests no keyboard. Its buffer stays transparent except for a centered 280×350 panel, so the launcher looks floating and the rest of the output is not dimmed. The transparent part remains inside the surface’s default input region: tapping it dismisses the launcher and consumes the tap instead of activating the application beneath. ui::Launcher::layout places a single column of application rows inside the rounded dark panel and centers that panel from runtime logical output size. The surface uses Patin’s deep-purple bar/lock palette. Its ten visible lines use normal-weight 14px text and small 18px icons; there is no visible scrollbar or per-row decoration.

Each row contains the localized application name and its desktop-entry icon. freedesktop-icons 0.4.0 resolves the icon through the GTK-selected theme, theme indexes and inheritance, XDG roots, hicolor, and pixmaps. The launcher then uses a bounded exact-name traversal of the selected theme and hicolor only when incomplete theme metadata hides a file that is actually installed. Environment roots retain priority, followed by canonical system and Flatpak roots so SSH or recovery-session environment differences do not hide icons. The launcher decodes PNG through image 0.25.10’s PNG-only feature and rasterizes SVG with resvg 0.47.0 with all default features disabled; a neutral square is used when no supported icon is available. The toolkit’s TextAlign::Start provides the left-aligned names, while DrawCommand::Image keeps decoded RGBA rendering behind Patin’s internal render boundary.

Shell::scroll_by is a defaulted vertical-scroll hook. The platform translates pointer-axis values into it. Touch contacts now activate on release only when they stayed within an eight-logical-pixel tap threshold; a drag instead emits scroll deltas. ui::Launcher::scroll_by advances a clamped visible window. This gives an fzf-like list motion without page controls, visible scroll chrome, or accidental launches while swiping.

The toolkit gains one general lifecycle hook: Shell::close_requested defaults to false, preserving every existing consumer. The platform checks it after updates and activations and exits its Calloop loop when a finite composition returns true. The launcher requests this after a successful spawn or a tap on empty background.

The shared startup diagnostic now says it is waiting for the compositor to configure a surface rather than a bar, since the same platform path also runs overlays.

Installation and compositor mapping

Install the standalone user binary:

./scripts/install-launcher-user.sh
patin-launcher

0xin can replace only its swipe launcher while leaving other Fuzzel uses alone:

gesture = top-down, spawn, pgrep -x patin-launcher >/dev/null || patin-launcher
gesture = to-top, spawn, pkill -x patin-launcher

These are ordinary spawn mappings. Patin contains no 0xin branch, and another layer-shell compositor may bind the same executable however it prefers.

Changed files and important functions

  • crates/patin-launcher owns desktop discovery/launch, freedesktop theme icon resolution and loading, scrolling list state, hit-testing, rendering, and the standalone overlay entrypoint.
  • src/ui.rs and src/render.rs add start-aligned text and decoded RGBA image commands plus an explicit normal/semibold text-weight choice.
  • src/platform.rs adds defaulted finite-composition and scroll hooks, pointer wheel translation, and tap-versus-drag touch handling.
  • scripts/install-launcher-user.sh builds the locked release package and installs only its executable under ~/.local/bin.
  • The workspace manifest and lockfile pin the new consumer and parser; README, architecture, environment, and mdBook navigation document the boundary.

Verification

Verified on 31 July 2026:

$ cargo fmt --all -- --check
(no output, exit 0)

$ cargo test --workspace --all-targets
all passed

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished, no warnings

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

Six launcher tests cover visible/hidden desktop entries, parsed launch arguments, reverse-DNS icon-name preservation, centered fixed-panel containment on a full-output surface, clamped scrolling, and outside-tap close requests. A short local smoke run found 13 launchable applications and resolved 9 icons before reporting the expected Could not find wayland compositor, because the tool session does not inherit the laptop’s graphical Wayland environment.

The phone-native six-test suite and optimized build passed for the final 280×350 floating list. After removing the stale Kitty desktop entry, a live run discovered 32 applications, resolved all 32 installed theme icons, connected to 0xin, and exited before its timeout when the transparent area outside the panel was tapped:

$ env -u LD_LIBRARY_PATH XDG_RUNTIME_DIR=/run/user/10000 \
  WAYLAND_DISPLAY=wayland-0 timeout 30 ~/.local/bin/patin-launcher
patin-launcher: discovered 32 launchable applications (32 resolved icons)
patin: connected; waiting for the compositor to configure the surface

Physical tap-to-launch and the final 0xin gesture switch remain to be recorded.

Stage 8b — Session Action Menu

Why this stage exists

The tested phone profile maps a two-second power-button hold to an external 0xin-session-menu script. That script previously piped four text choices into Fuzzel’s dmenu mode. Session policy already lived outside the compositor, so a separate Patin composition can replace only its visible menu without coupling the toolkit to 0xin.

patin-session is an optional consumer and not toolkit startup behavior. It does not construct the launcher, lock screen, bar, or phone-only modules.

Actions and compositor boundary

actions::configured always provides systemctl reboot and systemctl poweroff. Logout is compositor-specific and appears only when the launching integration sets PATIN_SESSION_LOGOUT_PROGRAM. An optional argument and label come from PATIN_SESSION_LOGOUT_ARGUMENT and PATIN_SESSION_LOGOUT_LABEL.

Action::launch passes the program and arguments directly to Command; it does not invoke a shell. For the tested 0xin session, the existing wrapper uses:

export PATIN_SESSION_LOGOUT_PROGRAM="$HOME/.local/bin/0xinctl"
export PATIN_SESSION_LOGOUT_ARGUMENT=quit
export PATIN_SESSION_LOGOUT_LABEL="Log out to Phrog"
exec "$HOME/.local/bin/patin-session"

The existing hold mapping can remain:

hold = , XF86PowerOff, 2000, spawn, ~/.local/bin/0xin-session-menu

Another compositor can supply its own logout command or omit that row.

Floating panel and outside dismissal

The binary creates an overlay-layer surface anchored to the complete output, with no exclusive zone and no keyboard request. The buffer remains transparent except for a centered deep-purple panel. With all three actions configured, SessionMenu::layout makes that panel 240×144 logical pixels and lays out three compact rows: Log out to Phrog, Reboot, and Shut down.

The transparent area stays in the Wayland surface’s default input region. A tap there calls SessionMenu::activate_at, finds no action row, and requests clean exit. The tap is consumed, so it does not activate the application underneath. There is deliberately no Cancel row.

If spawning an action fails, the menu stays open, logs the error, and adds a small error line to the panel. A successful spawn closes the menu immediately.

Changed files and important functions

  • crates/patin-session/src/actions.rs owns configured action policy and direct process spawning.
  • crates/patin-session/src/ui.rs owns centered layout, hit-testing, rendering, outside dismissal, and finite lifecycle state.
  • crates/patin-session/src/main.rs configures the transparent full-output layer surface and starts the composition.
  • scripts/install-session-user.sh installs only the standalone user binary.
  • Workspace, README, architecture, environment, and mdBook navigation changes document the new optional consumer.

Verification

Local verification on 1 August 2026:

$ cargo test -p patin-session --offline
3 passed

$ cargo clippy -p patin-session --all-targets --offline -- -D warnings
Finished, no warnings

Full verification also passed:

$ cargo fmt --all -- --check
(no output, exit 0)

$ cargo test --workspace --all-targets --offline
all passed

$ cargo clippy --workspace --all-targets --all-features --offline -- -D warnings
Finished, no warnings

$ mdbook build
INFO HTML book written to `/home/vdzee/proj/patin/book`

$ git diff --check
(no output, exit 0)

The phone-native three-test suite and optimized build passed. A safe live test placed a harmless systemctl shim first in PATH and used /bin/true for the logout row. The menu connected to 0xin and exited before its 30-second timeout when the transparent area was tapped. No logout, reboot, or power-off action was executed during verification.

The installed ~/.local/bin/0xin-session-menu now exports the tested 0xin logout program, argument, and label before executing patin-session. The existing two-second power-button hold mapping was left unchanged.

Stage 8c — Reproducible Linux CI

This maintenance stage makes the GitHub Actions build reproduce the native Linux prerequisites already required by the workspace.

Concept

Cargo manages Rust packages, but some crates bind to libraries supplied by the operating system. Patin enables smithay-client-toolkit’s xkbcommon feature, so SCTK’s build script asks pkg-config for xkbcommon. The optional patin-lock workspace member also links to PAM. A fresh Ubuntu GitHub runner does not promise the corresponding development files.

Development packages are different from runtime packages: they install the headers, unversioned linker inputs, and .pc metadata needed while compiling. On Debian and Ubuntu those packages are libxkbcommon-dev and libpam0g-dev. Installing them before Cargo runs prevents the SCTK build script from panicking because xkbcommon.pc cannot be found and prepares the complete workspace for the later lock-screen build.

Implementation

  • .github/workflows/ci.yml installs the two native development packages immediately after checkout.
  • README.md names the packages beside the build commands so a fresh local Debian/Ubuntu checkout has the same prerequisites as CI.
  • docs/environment.md explains the pkg-config discovery mechanism and the matching environment setup.
  • docs/SUMMARY.md links this stage into the book.

No Rust behavior, Wayland protocol handling, or runtime capability detection changes in this stage.

Verification

The following checks were run after the change:

  • cargo fmt --all -- --check — passed.
  • cargo test --workspace --all-targets — passed all 29 tests across the toolkit, examples, and workspace binaries/libraries.
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — passed with no warnings.
  • mdbook build — passed and wrote the HTML book to book/.
  • git diff --check — passed with no whitespace errors.

GitHub’s hosted runner will exercise the newly added apt-get step on the next push or pull request; the local machine already had the development libraries, so no privileged package installation was needed for these checks.