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_shmandtiny-skia; text is shaped and rasterized withcosmic-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::Provideragainst 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-lockis another consumer and is built/launched independently.patin-launcheris a third: an ordinary overlay-layer consumer with no compositor-specific integration.patin-sessionis 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-upowerpolls UPower’sDisplayDevice— the synthetic aggregate battery device UPower maintains for shells — overzbus’s blocking API.crates/patin-service-networkpolls 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 remainNone/falseinside a real snapshot; an unavailable system bus returnsNone.crates/patin-service-volumeandcrates/patin-service-brightnesshave no equivalent standard D-Bus interface to poll (noted in Status Providers), so they shell out towpctl/pactland read/sys/class/backlightrespectively.
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-skia0.12.0 for CPU raster primitives;cosmic-text0.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:
wpctl get-volume @DEFAULT_AUDIO_SINK@;pactl get-sink-volume @DEFAULT_SINK@plusget-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.Devices → Device.Wireless.ActiveAccessPoint →
AccessPoint.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:
- Foundation — establish the pinned Rust project, documentation, checks, license, and publishing automation.
- First surface — connect as a Wayland client, create a top layer-shell surface, reserve its exclusive zone, and fill it with a solid color.
- Rendering — introduce drawing primitives and a correctly scaled clock
using
tiny-skiaandcosmic-text. - Input — handle pointer and multitouch input; tapping a visible target changes bar state without affecting application focus.
- UI core — add internal row, column, and stack layout, styling, hit-testing, damage tracking, and reusable components.
- Service adapters — design optional provider interfaces from demonstrated battery, network, audio, notification, and media examples.
- Session lock — provide an independently launched, multi-output
ext-session-lock-v1composition with physical/touch password entry and PAM authentication. - Composition templates — exercise phone navigation, launchers, quick settings, notifications, and keyboard control without making them defaults.
- 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, andsrc/main.rsdefine the private Rust 2024 binary and pinned toolchain.AGENTS.mdmakes documentation and real verification part of every code change.book.toml,docs/, anddocs/SUMMARY.mdestablish the architecture, environment, roadmap, and stage record.- GitHub Actions check the project and publish this book.
README.md,.gitignore, andLICENSEestablish 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 -- --checkcompleted without formatting differences.cargo test --all-targetscompiled Patin and passed; there are intentionally no behavior tests before the first behavior exists.cargo clippy --all-targets --all-features -- -D warningscompleted without warnings.mdbook buildgenerated the HTML book successfully.git diff --checkreported 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.tomlpinssmithay-client-toolkit0.21.1 with only Calloop support. SCTK provides the protocol bindings, registry handling, surface/output state, and safe shared-memory slot pool.src/main.rsconnects to Wayland, binds required globals, configures the layer surface, dispatches events with Calloop, and submits the first buffer.src/render.rscontains 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
runowns startup: connection, registry enumeration, global binding, layer configuration, state construction, and the Calloop dispatch loop.Patin::configureaccepts 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::drawcreates and attaches the shared-memory buffer.fill_solid_argbwrites 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:
- creates a physical tiny-skia pixmap;
- fills the background and draws a two-logical-pixel accent rectangle;
- asks cosmic-text to shape and rasterize a right-aligned monospace clock;
- scales font metrics and padding with the physical scale;
- 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::physicalconverts logical lengths using protocol-native 120ths and ceiling division.CpuRenderer::render_baris the internal CPU renderer entry point.CpuRenderer::draw_clockowns cosmic-text layout and tiny-skia glyph compositing.Patin::request_redrawandPatin::framecoalesce 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::containsperforms the half-open logical-coordinate hit test.input::toggle_targetdefines target geometry independently of rendering.Patin::activate_atapplies the shared hit test, changes state, and requests a redraw.SeatHandler::new_capabilityandremove_capabilityfollow runtime pointer and touch availability for every seat.PointerHandler::pointer_frameaccepts only primary-button presses on Patin’s layer surface.TouchHandler::down,up, andcanceltrack overlapping contacts by touch object and contact ID.CpuRenderer::draw_toggledraws 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, andlinear_layoutimplement internal layout.Rect::containsandRect::insetprovide shared geometry operations.DemoBar::resizecomputes the example component bounds.DemoBar::action_atmaps an input position to an example action.DemoBar::activate_at,update, anddamage_allmutate demo state and record appropriate damage through the toolkit’sShelltrait.DemoBar::commandsbuilds the renderer-neutral example scene.CpuRenderer::render_barexecutes generic draw commands.Patin::drawconverts 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:
platformowns Wayland connection, layer surfaces, seats, pointer/touch routing, scaling, shared-memory buffers, frame callbacks, and physical damage;renderexecutes renderer-neutral fill and text commands on the CPU backend;uisupplies 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;
wpctlandpactlvolume 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.NetworkManagerat/org/freedesktop/NetworkManagerexposesPrimaryConnection(an object path,"/"when there is none) andPrimaryConnectionType(a string —"802-11-wireless"was the live value on the FP5’s wifi connection).- A wifi signal percentage takes a real walk: the
ActiveConnectionobject’sDevices→ thatDevice’sorg.freedesktop.NetworkManager.Device.Wireless.ActiveAccessPoint→ thatAccessPoint’sStrength, ay(byte, 0–100) — confirmed68live, no scaling needed unlike UPower’sf64percentage. - 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 (includinggsm/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.rspreserves provider snapshots instead of converting them to labels.examples/demo_bar/scene.rsrendersbattery_icon,volume_icon,wifi_icon,wired_icon, andcellular_icon, with shared centering and shape helpers plus state- and layout-regression tests.README.mdanddocs/status-services.mddescribe the visible behavior and retain the toolkit/example boundary.docs/SUMMARY.mdlinks 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
SIGUSR1sent to the--workerprocess, checked once per dispatch loop iteration. Useful for scripted/SSH testing, e.g.:
(pkill -USR1 -f -- '--worker'pkill -fmatches 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 inpress_keyas an ordinary keyboard event. This needed no compositor config at all, once we understood why an earlier attempt (a compositor keybind spawning theSIGUSR1command above) didn’t work: 0xin’shandle_keybindingshort-circuits withif 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 deliversXF86PowerOfftopatin-lockas a normalwl_keyboardevent 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 (whichext-session-lock-v1more 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=fulland--keypad=numericboth 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 theserver.lockedkeybind-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_boundsis the compact rounded shape that gets drawn.hit_boundsis 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.rsowns the newKeyLayout, adaptive keyboard geometry, role colors, field presentation, hit-testing, and regression tests.README.mddescribes the user-visible keyboard and placeholder behavior.docs/environment.mdnames xkbcommon’s development files as a lock-build requirement.docs/SUMMARY.mdlinks 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-launcherowns desktop discovery/launch, freedesktop theme icon resolution and loading, scrolling list state, hit-testing, rendering, and the standalone overlay entrypoint.src/ui.rsandsrc/render.rsadd start-aligned text and decoded RGBA image commands plus an explicit normal/semibold text-weight choice.src/platform.rsadds defaulted finite-composition and scroll hooks, pointer wheel translation, and tap-versus-drag touch handling.scripts/install-launcher-user.shbuilds 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.rsowns configured action policy and direct process spawning.crates/patin-session/src/ui.rsowns centered layout, hit-testing, rendering, outside dismissal, and finite lifecycle state.crates/patin-session/src/main.rsconfigures the transparent full-output layer surface and starts the composition.scripts/install-session-user.shinstalls 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.ymlinstalls the two native development packages immediately after checkout.README.mdnames the packages beside the build commands so a fresh local Debian/Ubuntu checkout has the same prerequisites as CI.docs/environment.mdexplains thepkg-configdiscovery mechanism and the matching environment setup.docs/SUMMARY.mdlinks 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 tobook/.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.