Skip to content

ThomasFunk/wayctl

Repository files navigation

wayctl

wayctl is a script-friendly CLI for Wayland compositors with clear JSON output and a stable command model.

Unlike swaymsg or hyprctl, wayctl uses the Wayland protocol stack (wayland-protocols and wlr-protocols) to provide relevant information from the compositor, as well as managed outputs, workspaces, and windows. Where that is not possible, conservative heuristic methods are used (for example, for PID matching).

Actions are also possible through the Wayland protocol stack. Where that is not sufficient, wayctl falls back to key sending (set/send send_key).

Table of Contents

Features

  • Structured queries: get toplevels, get workspaces, get outputs
  • Direct window control: focus, close, fullscreen, maximized, minimized
  • Workspace control (depending on compositor capabilities): switch, create, remove, assign
  • Action/fallback layer: set/send trigger_action (TOML-driven) and set/send send_key
  • Configuration tools: render labwc, config lint, config lint --strict
  • Global logging: --verbose [LEVEL] and --log [PATH] with invocation headers and run summaries

Support Status

  • Directly supported: central GET and SET/SEND paths and deterministic target selection via session-stable program_id, cross-process selectors (--pid, --title, --app-id), or handle_id fallback
  • Partially supported: labwc actions, currently running via TOML + trigger_action
  • Not directly supported: runtime workspace rename and deterministic set/send rectangle

Special Features

Beyond the basic features, wayctl offers several advanced capabilities:

  • PID Heuristic (-H pid)
    Stack-order-based mapping of Wayland toplevels to process IDs. Stack matching is the primary strategy; when ambiguous, a configurable filter pipeline (pid_matchers.toml) is used as fallback. The pid_state field reports process status (running/defunct/dead) for matcher = "pid"; it is always null for matcher = "ppid" (Wayland does not expose which child process owns a specific window). No shell expansion — only allowlisted filter commands.

  • Configuration Validation (config lint --strict)
    TOML file, key combinations, and mappings are verified before processing. With --strict, warnings are also treated as errors — ideal for CI/CD.

  • Action Mapping Model (TOML-driven)
    Logical actions can be mapped both via direct protocol commands (direct = [...]) and labwc actions (action = "..."). A single wayctl_actions.toml serves multiple compositors (labwc, Hyprland, niri, etc.).

  • Fallback Filters for Process Lookup (pid_matchers.toml)
    When stack order is not unique, matcher rules use command pipelines (grep, awk, sed, ...) for process selection. Each filter is executed individually; timeouts and line limits are hardcoded (plus optionally tightened via TOML).

  • Runtime Logging and Diagnostics
    Optional stderr/file logging with levels (TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL). Use --verbose to print logs and --log to append to a file (./wayctl.log, fallback ~/.config/wayctl.log). Each invocation writes a header (separator, timestamp, full command) and a structured summary line: run_summary command_group=... exit_code=... duration_ms=.... Log files are automatically rotated by size; configure rotation policy and other defaults in wayctl.toml.

  • Configuration File (wayctl.toml)
    Central configuration file for logging, CLI defaults, and timeouts. Loaded from ./wayctl.toml or ~/.config/wayctl/wayctl.toml. Includes sections for logging (log level, file path, rotation settings), CLI defaults (verbose, pretty, heuristic_pid), and PID matcher timeouts. All settings are optional; defaults are sensible for standard use.

  • Daemon IPC Monitoring (daemon + monitor.subscribe)
    wayctl can run as a background daemon over Unix socket IPC (daemon start). Clients can subscribe to event streams with monitor.subscribe and topic filters (daemon, command, toplevel, workspace, output, *). The daemon emits command-completion events and change events for get toplevels/workspaces/outputs. For diagnostics, monitor events can additionally be mirrored to stdout or a JSONL sink file. Snapshot commands now explicitly release/destroy Wayland protocol proxies after each poll cycle to keep long-running daemon loops stable and avoid native pywayland/libwayland teardown crashes.

Daemon IPC Handshake (ld-icons/nsd compatible)

IPC clients can perform a lightweight capability handshake before sending commands:

  1. Send action = "hello" to receive protocol identity and supported features.
  2. Send action = "capabilities" to receive full action and topic lists.

Example hello request envelope:

{"src":"client","type":"command","action":"hello","payload":{},"expect_response":true,"request_id":"req-hello-1","version":"1.0"}

Example monitor.subscribe request envelope:

{"src":"client","type":"command","action":"monitor.subscribe","payload":{"subscriptions":["workspace","output"]},"expect_response":true,"request_id":"req-sub-1","version":"1.0"}

Example event emitted by daemon:

{"src":"wayctl.daemon","type":"event","action":"monitor.workspace.changed","payload":{"topic":"workspace","action":"get.workspaces","data":{}},"version":"1.0"}

Open Issues / Feature Requests

Current Status (Version: 0.8.1)

  • Wayland registry connection is implemented.
  • zwlr_foreign_toplevel_manager_v1 is bound.
  • CLI is structured as an extensible command system with namespaces.

CLI Structure

The command line is hierarchical so new commands can grow cleanly:

  • get ... for read-only queries
  • set/send ... for state-changing commands
  • version as a global meta command

Labwc Action Reference (Grouped by Topic)

The following matrix replaces the earlier example and note blocks. It shows at a glance what wayctl already implements directly, what runs through trigger_action, and what is still open.

Status legend:

1. Window Management (State and Layer)

Labwc Action Description (Functionality) wayctl Command Notes Implemented
Focus Sets keyboard focus to a window. set/send focus <program_id>, set/send focus --pid <PID>, set/send focus --title "title", set/send focus --app-id app.id Session-stable program_id is preferred; alternatives work cross-process. yes
Raise Raises a window to the top within its layer. set/send trigger_action <name> Only available through labwc action mapping. partial
Lower Pushes a window to the bottom within its layer. set/send trigger_action <name> Only available through labwc action mapping. partial
Close Sends a close request to the application. set/send close_window <program_id> or cross-process selectors Direct foreign toplevel path with multiple selector options. yes
Kill Terminates the window process immediately. set/send trigger_action <name> No direct wayctl API command; mapping only. partial
Iconify Minimizes a window. set/send minimized <program_id> on or cross-process selectors Direct state path available. yes
Unminimize Restores a minimized window. set/send minimized <program_id> off or cross-process selectors Direct state path available. yes
Maximize Maximizes a window. set/send maximized <program_id> on or cross-process selectors Direct state path available. yes
Unmaximize Restores the original window size. set/send maximized <program_id> off or cross-process selectors Direct state path available. yes
ToggleMaximize Switches between maximized and normal state. set/send trigger_action <name> Toggle behavior is only available through action mapping; the direct command is state-based (on/off). partial
ToggleFullscreen Switches fullscreen on or off. set/send fullscreen <program_id> on/off, optionally set/send trigger_action <name> Direct state path with multiple selector options; toggle via action mapping. yes
ToggleDecorations Toggles server-side decorations. set/send trigger_action <name> No direct wayctl API command. partial
ToggleAlwaysOnTop Pins a window into the top layer. set/send trigger_action <name> No direct wayctl API command. partial
ToggleAlwaysOnBottom Moves a window into the bottom layer. set/send trigger_action <name> No direct wayctl API command. partial
ToggleOmnipresent Makes a window sticky across all workspaces. set/send trigger_action <name> No direct wayctl API command. partial

Examples — Window Management:

# Focus a terminal by program_id (session-stable selector)
wayctl send focus wctl-123

# Minimize all browser windows by app-id
wayctl send minimized --app-id firefox on

# Maximize a window, then undo it via normalize
wayctl send maximized wctl-123 on
wayctl send normalize wctl-123

2. Window Positioning and Interaction

Labwc Action Description (Functionality) wayctl Command Notes Implemented
Move Starts interactive moving. set/send trigger_action <name> Only available through action mapping. partial
Resize Starts interactive resizing. set/send trigger_action <name> Only available through action mapping. partial
MoveToEdge Moves a window to a screen edge. set/send trigger_action <name> Only available through action mapping. partial
SnapToEdge Snaps a window to an edge (50%). set/send trigger_action <name> Only available through action mapping. partial
SnapToRegion Moves a window into a predefined region. set/send trigger_action <name> Only available through action mapping. partial
MoveToCursor Centers a window under the cursor. set/send trigger_action <name> Only available through action mapping. partial
VirtualLineUp / VirtualLineDown Simulates mouse wheel scrolling up and down. set/send trigger_action <name> or set/send send_key <combo> Typical use goes through keybinding mapping. partial
set_rectangle (foreign toplevel hint) Passes a position and size hint to the compositor. set/send rectangle <program_id> <x> <y> <width> <height> Intentionally returns unsupported for now; the wl_surface object from the same session is missing. See labwc_feature_requests.md. no

Examples — Window Positioning:

# Set focus on window with program_id wctl-42 and then snap the focused window to the left half via TOML action
wayctl send focus wctl-42 && wayctl send trigger_action snap_left

# Move the focused window to the right screen edge via TOML action
wayctl send trigger_action move_right_edge

# Scroll down in a window without moving the mouse (virtual scroll)
wayctl send trigger_action scroll_down

3. Workspaces and Activities

Labwc Action Description (Functionality) wayctl Command Notes Implemented
GoToDesktop Switches workspace (index or relative). set/send switch_workspace <workspace_id/number/#index/workspace_name> or set/send trigger_action <name> The direct wayctl path is preferred for deterministic target selection. yes
SendToDesktop Sends the active window to another workspace. set/send trigger_action <name> No direct wayctl API command. partial
NextWindow Moves focus to the next window. set/send trigger_action <name> Only available through action mapping. partial
PreviousWindow Moves focus to the previous window. set/send trigger_action <name> Only available through action mapping. partial
DirectionalFocus Moves focus in a direction. set/send trigger_action <name> Only available through action mapping. partial
Create workspace Creates a new workspace in a group. set/send create_workspace <group_id> <name> Works only if the compositor advertises the corresponding capability. See labwc_feature_requests.md. partial
Remove workspace Removes an existing workspace. set/send remove_workspace <workspace_id/number/workspace_name> Works only if the compositor advertises the corresponding capability. See labwc_feature_requests.md. partial
Assign workspace Assigns a workspace to a group. set/send assign_workspace <workspace_id/number/workspace_name> <group_id> Works only if the compositor advertises the corresponding capability. See labwc_feature_requests.md. partial
Rename workspace Renames a workspace at runtime. set/send rename_workspace <workspace_id/number/workspace_name> <new_name> Intentionally returns unsupported; ext_workspace_v1 has no rename request. See freedesktop_feature_request.md and labwc_feature_requests.md. no

Examples — Workspaces:

# Switch to workspace 2 by index
wayctl send switch_workspace #2

# Switch to an existing workspace by name
wayctl send switch_workspace dev

# Send the active window to workspace 2 via action mapping
wayctl send trigger_action send_to_workspace_2

4. Logic, Control, and System

Labwc Action Description (Functionality) wayctl Command Notes Implemented
If Executes part of an action chain conditionally. set/send trigger_action <name> Only indirectly available through labwc actions. partial
ForEach Applies an action to multiple windows. set/send trigger_action <name> Only indirectly available through labwc actions. partial
Execute Runs an external command. set/send trigger_action <name> Only indirectly available through labwc actions. partial
ShowMenu Opens a menu at the cursor. set/send trigger_action <name> Only indirectly available through labwc actions. partial
Reconfigure Reloads the labwc configuration. set/send trigger_action <name> Only indirectly available through labwc actions. partial
Exit Ends the session. set/send trigger_action <name> Only indirectly available through labwc actions. partial
Stop Stops further action processing. set/send trigger_action <name> Only indirectly available through labwc actions. partial
None Intentionally performs no action. set/send trigger_action <name> Only indirectly available through labwc actions. partial

Examples — Logic, Control, and System:

# Reload labwc configuration after editing rc.xml
wayctl send trigger_action reconfigure

# Open the root context menu at the current cursor position
wayctl send trigger_action show_menu

# Kill a frozen window immediately via TOML action
wayctl send trigger_action kill_window

Base Commands (Without a Direct Labwc Action Mapping)

wayctl Command Notes
get toplevels Snapshot of all detected toplevels with program_id (session-stable), title, app_id, states, and optional pid. Optional stabilization flags: --stabilize, --stabilize-timeout-ms, --stabilize-roundtrips.
get workspaces Requires ext_workspace_manager_v1; if privilegedInterfaces is used, <allow> must be set.
get outputs Requires zwlr_output_manager_v1; if privilegedInterfaces is used, <allow> must be set.
set/send close_window <program_id> Sends a compositor close request. status=closed means the window disappeared quickly; status=close_requested means the request was accepted but the app may still show a save/discard dialog.
set/send normalize <program_id> Resets sticky window states in one call (fullscreen=off, maximized=off, minimized=off). Useful as a quick recovery path when interactive resize is blocked by window state.
render labwc Renders an XML keybind block from wayctl_actions.toml for rc.xml.
config lint Validates the TOML schema and semantic collisions.
config lint --strict Same as config lint, but additionally treats warnings as errors for CI hardening.
Global option -c / --conf Selects the TOML file explicitly; otherwise the search path is ./wayctl_actions.toml followed by ~/.config/wayctl/actions.toml.
version Returns tool metadata as JSON.

General Action Mappings via TOML

The wayctl_actions.toml file defines the general mapping between logical actions and compositor-specific shortcuts. This keeps wayctl compositor-agnostic, while labwc, niri, Hyprland, or other compositors can provide their own bindings.

Key combo range: F13–F24

The example combos in this file use W-C-A-S-F13 through W-C-A-S-F23. These keys do not exist as physical keys on standard keyboards, but F13–F24 are valid XKB keysyms with corresponding Linux evdev keycodes (KEY_F13–KEY_F24). They are effectively collision-free: no system shortcut will ever accidentally fire one of these combos. wayctl automatically patches the XKB keymap at runtime when the system keymap does not include F13–F24 (which is the case for most standard pc/evdev layouts, regardless of BIOS Fn-Lock settings). If a custom keymap already maps F13 directly, the patch is skipped.

Example:

version = 1
active_profile = "labwc"

[actions.toggle_fullscreen]
description = "Toggle fullscreen on the focused window"

[actions.toggle_fullscreen.backends.labwc]
combo = "W-C-A-S-F13"
action = "ToggleFullscreen"

[actions.close_window.backends.labwc]
combo = "W-C-A-S-F15"
action = "Close"
direct = ["send", "close_window", "{focused}"]

Placeholder tokens for direct = [...]:

  • {focused} resolves at runtime to the currently active toplevel handle.
  • {target} resolves to an explicitly selected target token. For toplevel commands, use positional selector (default program_id) or --program-id, --pid, --title, --app-id. For workspace commands like switch_workspace, use positional selector or --program-id with a workspace token.

exec, var_* and notify

When a direct protocol path is not available, exec lets you run arbitrary external commands directly from the TOML binding:

[actions.screenshot_focused_window.backends.labwc]
combo = "W-Print"
var_out = "$(xdg-user-dir PICTURES)/screenshot_{timestamp}.png"
exec = ["sh", "-c", "grim -g \"$(slurp)\" \"$var_out\""]
notify = "Screenshot saved:\n$var_out"

exec — argv list executed via subprocess (no shell). combo becomes optional; when both are set, wayctl render labwc emits an Execute keybind block automatically.

var_* variables — user-defined string templates resolved before substitution into exec and notify:

  1. Built-in {placeholder} tokens are substituted first.
  2. Any remaining $(...) expressions are expanded via sh.
  3. The resolved string is stored and substituted as $var_name.

Built-in placeholders available in exec tokens, var_* values, and notify:

Placeholder Resolves to
{timestamp} Current time as HH-MM-SS
{date} Current date as YYYY-MM-DD
{focused_title} Title of the activated toplevel
{focused_app_id} App ID of the activated toplevel
{focused_program_id} Program ID of the activated toplevel

notify — sends a desktop notification via notify-send on exec success. Supports the same {placeholder} and $var_name substitution as exec.

Example:

wayctl send trigger_action close_window wctl-42

Labwc Action Reference for TOML Mappings

Labwc action names and attributes form the backend-specific mapping layer for wayctl_actions.toml. For automation, the main rule is:

  • If wayctl already has a stable direct protocol path, direct = [...] should be preferred.
  • Labwc actions are especially useful when the behavior is explicitly a toggle, a cycle action, or a labwc-specific workspace navigation path.
Logical Action labwc action Important attrs Recommended wayctl Path
Close window Close none Prefer direct: direct = ["send", "close_window", "{focused}"]
Toggle fullscreen ToggleFullscreen none For true toggle behavior via labwc action; for explicit state use set/send fullscreen <program_id> on/off
Toggle maximization ToggleMaximize optional direction = both or horizontal or vertical For toggle behavior via labwc action; for explicit state use set/send maximized <program_id> on/off
Minimize window Iconify none Directly possible for state on: set/send minimized <program_id> on; no direct toggle exists
Next window NextWindow optional workspace = current or all, output = all or focused or cursor, identifier = all or current Labwc action only
Previous window PreviousWindow optional workspace = current or all, output = all or focused or cursor, identifier = all or current Labwc action only
Switch workspace GoToDesktop to, optional wrap, optional toggle Prefer direct for deterministic targeting: set/send switch_workspace <workspace_id/number/#index/name>
Send window to workspace SendToDesktop to, optional follow, optional wrap Labwc action only
Sticky / on all workspaces ToggleOmnipresent none Labwc action only
Focus window under cursor Focus none Labwc action only; not suitable as a replacement for handle-based targeting

Examples of TOML mappings with labwc attributes:

[actions.workspace_left]
description = "Switch to the workspace on the left"

[actions.workspace_left.backends.labwc]
combo = "W-C-A-S-F16"
action = "GoToDesktop"
attrs = { to = "left", wrap = "yes" }

[actions.send_to_workspace_2]
description = "Send the focused window to workspace 2 and follow it"

[actions.send_to_workspace_2.backends.labwc]
combo = "W-C-A-S-F17"
action = "SendToDesktop"
attrs = { to = "2", follow = "yes", wrap = "yes" }

[actions.next_window_current_output]
description = "Cycle windows on the focused output"

[actions.next_window_current_output.backends.labwc]
combo = "W-C-A-S-F18"
action = "NextWindow"
attrs = { workspace = "current", output = "focused", identifier = "all" }

Source for this reference: labwc labwc-actions(5), default bindings, and src/action.c (action names plus attribute parsing and defaults).

Layer-Shell Preparation

wayctl does not yet provide its own get layer_surfaces or set/send layer_surface path. For the later API, labwc semantics are already clear enough to define the target mapping.

Future wayctl State labwc / zwlr-layer-shell Semantics Notes
passthrough = true keyboard_interactivity = none The layer surface does not take keyboard focus; input continues through normal focus rules
exclusive = true keyboard_interactivity = exclusive The layer surface blocks normal toplevel focus until focus is explicitly taken or released
passthrough = false, exclusive = false keyboard_interactivity = on-demand Focus only on normal user interaction; in labwc this makes sense for top/overlay, while background/bottom should not steal focus
margin = {top,right,bottom,left} per-surface layer-shell margins Belongs to the layer surface itself, not to global output configuration
reserved_area / exclusive_zone exclusive_zone affects output->usable_area labwc reduces the usable area for normal windows before placement, maximize, and tiling

Important design rules derived from labwc:

  1. exclusive_zone and margin are not the same thing. Layer-shell margins are surface-local. By contrast, labwc <margin> in rc.xml is a compositor-wide output override for panels or docks without layer-shell support.

  2. passthrough is not its own protocol field. In wayctl, passthrough should be treated as a readable abstraction for keyboard_interactivity = none.

  3. On-demand focus is intentionally restrictive. labwc does not allow on-demand layer surfaces in background/bottom to steal focus freely; in practice it mainly makes sense for top/overlay, such as panel menus, launchers, or nags.

  4. output->usable_area is the actual effect of reserved layer space. Placement, maximize, tiling, and SSD resize extents in labwc are based on usable_area, not directly on individual layer surfaces.

  5. Global <margin> remains the fallback for non-layer-shell clients. If a panel does not speak zwlr_layer_shell_v1, labwc <margin> is the fallback model. A future wayctl API should not mix those two levels.

Practical consequence for the future wayctl API:

  • GET side: fields such as layer, keyboard_interactivity, exclusive_zone, margin, output, anchors, mapped
  • Derived JSON flags: for example passthrough, exclusive, reserves_space
  • No premature toggle API design: layer-shell is stateful and surface-specific, not as stably addressable as toplevel handles

PID Selector and Portability

wayctl supports --pid <PID> as an alternative targeting scheme for toplevel control commands. The following fallback rules apply:

  1. No Wayland protocol exposes PIDs natively. Neither zwlr_foreign_toplevel_manager_v1 nor ext_foreign_toplevel_list_v1 contain a PID field. The pid field in get toplevels output therefore remains null by default.

  2. --pid fails when no enrichment source is available. Without externally enriched PID data (enrich_pid()), get_by_pid() returns not_found immediately. No guessing takes place.

  3. Ambiguity is rejected hard. If two toplevel handles carry the same PID, for example in a multi-window app, --pid returns ambiguous; it does not silently choose an arbitrary window.

  4. Recommended primary path: program_id from get toplevels (session-stable across process boundaries). Fallback selectors include --pid, --title, --app-id, or legacy handle_id (ephemeral, same-process only).

  5. app_id heuristics are intentionally not implemented. Multiple instances of the same app share the same app_id; matching on it would be fragile and could target the wrong window.

The default key combinations are intentionally unusual so they do not block day-to-day shortcuts.

PID Enrichment Heuristics

get toplevels supports optional best-effort PID enrichment via -H pid / --heuristic pid. When enabled, wayctl tries to fill pid, ppid, and pid_state in the toplevel JSON output.

Stack-Order Matching (Primary Strategy)

This is the primary strategy and is applied first for each app_id group:

  1. Wayland toplevel records are collected in compositor stack order (oldest first).
  2. Matching processes are collected via ps -o pid=,stat= -C <process_name>.
  3. If the process count exactly matches the toplevel count for that app_id, records are matched positionally.

pid_state is derived from ps STAT values:

  • running (default)
  • defunct (Z)
  • dead (X)

Filter-Based Fallback (pid_matchers.toml)

If stack-order matching cannot assign PIDs because counts differ, wayctl uses matcher rules from pid_matchers.toml.

  • Matcher config is loaded from the first existing default location:
    • ./pid_matchers.toml
    • ~/.config/wayctl/pid_matchers.toml
  • Rules are configured per app_id.
  • filters are a command pipeline (argv lists) applied to ps output.
  • alias can override the process name used for ps -C.
  • extra_stats can add extra ps columns (for example ppid) for matcher decisions.
  • matcher = "pid" performs positional PID assignment when filtered counts match.
  • matcher = "ppid" requires a single unambiguous parent PID candidate; all matching toplevels receive ppid (the parent PID). pid and pid_state remain null (see Safeguards and Limitations).

Example:

[code]
extra_stats = "ppid"
filters = [["grep", "server.bundle.js"]]
matcher = "ppid"

[google-chrome]
alias = "chrome"
extra_stats = "ppid"
filters = [["awk", "'$0 !~ /--type=/ {print $0}'"]]
matcher = "ppid"

Field reference:

Field Type Default Notes
alias string app_id Optional process-name override used with ps -C.
extra_stats string or list[string] empty Optional extra ps columns. Common example: ppid.
filters list[list[string]] none Required command pipeline (argv lists). Legacy list[string] is treated as grep needles.
matcher "pid" or "ppid" "pid" pid: positional PID assignment on exact match count. ppid: requires one unambiguous parent PID.
step_timeout_ms int bounded default Optional per-step timeout; cannot exceed hard-coded max.
total_timeout_ms int bounded default Optional end-to-end timeout; cannot exceed hard-coded max.
max_output_lines int bounded default Optional output cap for ps and filter stages; cannot exceed hard-coded max.

Safeguards and Limitations

  • The heuristic is best-effort and never a protocol guarantee; Wayland toplevel protocols do not expose PIDs natively.
  • Assignment is conservative: if matching is not reliable, fields remain null instead of guessing.
  • Filter commands run with shell=False (hardcoded) and cannot use shell expansion.
  • Only allowlisted filter commands are executed.
  • Runtime safety limits are hard-capped in code (per-step timeout, total timeout, max output lines); config can only tighten them.
  • app_id and process names can differ (for example wrappers, Flatpak, sandboxing).
  • Snapshot races are possible (window/process lifecycle changes between Wayland snapshot and ps).
  • A single process can own multiple toplevels, which can reduce deterministic matching quality.
  • pid_state is always null for matcher = "ppid" entries. The Wayland protocol does not expose which child process owns a specific window, and neither ps --ppid nor socket inspection can reliably link a renderer PID to a specific toplevel. This is by design — ppid is still available in the toplevel output and usable with --pid.
  • If matcher = "ppid" resolves a parent PID of 1 (init/systemd), the heuristic logs a warning and skips assignment. This guards against re-parented or system-service processes producing spurious matches.

Render a Labwc Block from TOML

For labwc, an XML block can be rendered from the same TOML file:

python wayctl.py render labwc

Example output:

<keyboard>
  <keybind key="W-C-A-S-F13">
    <action name="ToggleFullscreen" />
  </keybind>
</keyboard>

That block can then be copied into rc.xml. For other compositors, the TOML file remains the same source of truth; only the target format changes.

If labwc uses a <privilegedInterfaces> block, zwp_virtual_keyboard_manager_v1 must also be allowed there for set/send send_key and set/send trigger_action.

TOML Hardening

The following command validates the mapping file before use or in CI:

python wayctl.py -c ./wayctl_actions.toml config lint
python wayctl.py -c ./wayctl_actions.toml config lint --strict

--strict treats warnings as errors and is therefore suitable for stricter CI checks.

Validation currently covers, among other things:

  • schema hardening (version = 1, valid identifiers, required fields)
  • parsability of all key combinations
  • collisions of the same key combination within a backend

Notes on get workspaces:

  • The compositor must provide ext_workspace_manager_v1.
  • If a privilegedInterfaces block is used, ext_workspace_manager_v1 must be allowed there.
  • number is derived from numeric workspace names (for example 1 or Workspace 1) and is null for non-numeric names.
  • workspace_id_dbg is a debug-only field and is only included with -v DEBUG or -v TRACE.

Notes on get toplevels:

  • outputs contains resolved monitor names (for example X11-1, eDP-1).
  • ppid is set by matcher = "ppid" entries and can be used with --pid selectors.
  • outputs_dbg, pid_state, and parent_id are debug-only fields and are only included with -v DEBUG or -v TRACE.
  • --stabilize runs extra roundtrips until consecutive snapshots match (bounded by --stabilize-timeout-ms, default 300).
  • --stabilize-roundtrips controls how many identical consecutive snapshots are required (default 2).

Notes on get outputs:

  • The compositor must provide zwlr_output_manager_v1.
  • If a privilegedInterfaces block is used, zwlr_output_manager_v1 must be allowed there.

Architecture at a Glance

wayctl is built around a simple, extensible command system:

  • Each command lives in its own module under wayctl_core/commands/. No changes to the main entry point required.
  • CommandSpec (leaf) and CommandGroupSpec (group) are immutable dataclasses from base.py. Both implement the CommandNode protocol with register() for argparse wiring.
  • __init__.py aggregates all modules via all_commands() into the CLI groups get, set, render, config, daemon.
  • Every handler opens its own short-lived Wayland connection (with WaylandRegistryClient()), runs two roundtrips (announce handles, drain metadata), and returns a JSON-serializable dict.
  • Resolvers (ToplevelHandleResolver, workspace/group resolvers) provide deterministic target selection with no silent guessing — ambiguity or no match always yields a structured error payload.
  • Output format: get commands always return a schema envelope wayctl.get.v1.0; set commands always return at least status.

Detailed technical documentation (dataclasses, roundtrip model, resolver priorities, module template, test conventions) lives in wayctl_core/commands/README.de.md.

Installation

Runtime install (no venv)

For normal usage, install wayctl as a script into your prefix bin directory:

make install PREFIX=~/.local

Or system-wide:

sudo make install PREFIX=/usr/local

make install now uses the system Python dependency check and does not activate or rely on the project venv.

Before copying the executable, the Makefile runs check-install-deps and verifies that runtime packages from requirements.txt are available in system Python. If packages are missing, installation stops with a clear message.

If you want more console output from Make:

make V=1 install PREFIX=~/.local

Development install (with venv)

For contributors and test/dev workflows:

make dev-install

Alias:

make install-dev

Development

Instead of using make install-dev, you can perform all steps manually:

source venv/bin/activate
pip install -r requirements.txt
python wayctl.py --help
pytest -q tests/test_wayland_registry.py

Tests

This project currently uses pytest for the core registry and binding area.

Quick focused test run:

source venv/bin/activate
pytest -q tests/test_wayland_registry.py

Run the full project test suite:

source venv/bin/activate
pytest -q

CLI smoke test without Wayland operations:

source venv/bin/activate
python wayctl.py --help
python wayctl.py version

Live smoke tests (active Wayland session required):

WAYLAND_DISPLAY=wayland-0 python wayctl.py -p get toplevels
WAYLAND_DISPLAY=wayland-0 python wayctl.py -p get workspaces
WAYLAND_DISPLAY=wayland-0 python wayctl.py -p get outputs

If a test fails:

  • use pytest -vv for more detail
  • if imports fail, run pip install -r requirements.txt again first
  • for Wayland-related failures, verify that the session and environment are set correctly

Output Format

  • JSON output uses UTF-8 (ensure_ascii=False) so characters such as umlauts remain directly readable, for example Arbeitsfläche 1 instead of \u00e4 escapes.
  • All get commands return a unified schema envelope wayctl.get.v1.0 with schema (name + query) and normalized meta.counts.
  • Error output is structured (title, details, next steps) and includes concrete hints about the Wayland session and interface permissions.

Contributing, Bugs, and Feature Requests

Contributions are welcome. Please use the repository for issues, pull requests, and feedback.

Help is currently most needed in these areas:

  • Compositor coverage: wayctl is primarily developed and tested against labwc. Testing and feedback for other compositors (Hyprland, niri, KWin, Sway, ...) help identify protocol differences and incompatibilities early.

  • pid_matchers.toml: Help with robust matcher rules for additional programs is highly appreciated, so get toplevels can enrich pid/ppid more reliably.

  • wayctl_actions.toml: The file currently contains mostly test commands. A practical baseline with useful default actions per backend is needed.

Note: Earlier external feature-request references were removed. This documentation intentionally focuses only on the currently verified feature set and the roadmap maintained within this project.

Authors

  • Thomas Funk (lead author, strategy, manual testing)
  • GitHub Copilot Pro (lead implementation, automated testing, documentation)

About

A script-friendly CLI for Wayland compositors with clear JSON output and a stable command model.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors