Initial Lime window manager

This commit is contained in:
2026-10-02 14:14:47 -05:00
commit 785c2e8968
103 changed files with 27259 additions and 0 deletions

487
docs/architecture.md Normal file
View File

@@ -0,0 +1,487 @@
# Architecture
Lime is arranged so that task/workflow semantics stay independent of X11, and
so that X11 protocol work can grow without becoming one giant event handler.
```text
key / menu / IPC / pointer MapRequest / DestroyNotify
| |
v v
x11/input, x11/ui x11/lifecycle
| |
+--------------> x11/commands <---------------+
|
v
lime.core
|
v
x11/render: plan + CLX executor
```
## Core model
`src/model.lisp`, `src/layout.lisp`, `src/operations.lisp`, and
`src/topology.lisp` make no X11 calls. They own Activities, Views, Heads,
Presentations, Leases, layout resolution, collision planning, topology-plan
reconciliation, and model focus state. Every core transition must preserve the
one-lease invariant and be testable without an X server.
`src/membership.lisp` owns explicit home-Activity moves, shared eligibility,
and guarded Activity deletion (empty and unmounted only, with reference cleanup).
It prepares affected View/draft copies before mutation, then commits ownership,
layout/focus cleanup and lease release together. The adapter chooses destination
draft and layout policy; neither menus nor X events implement membership rules.
`make-client-private-in-presentation` atomically adopts the current Activity as
home and revokes sharing. It prunes other Activities' saved/live references
without replacing the current lease or changing placement, focus, or dirty state.
The lower-level, context-free `set-client-shared` retains its existing-home
revocation semantics for callers deliberately operating without a live desk.
`src/topology.lisp` compares a desired list of logical Head ids with the
current model and builds an explicit move/suspend plan. It knows nothing about
physical outputs: a removed live Presentation either moves whole to a caller's
chosen destination or becomes an unbound, lease-free cached draft. This keeps
hotplug routing reusable from an X11 menu, IPC, or test fixture.
`src/layout-operations.lisp` is the focused, Head-local layout command layer:
it selects manual/master-stack/monocle policy, promotes a master, and edits a
focused split or visible tiled slots. It changes only a live Presentation;
View persistence remains an explicit operation in `operations.lisp`.
`src/floating-operations.lisp` is the equally small Head-local floating layer:
it toggles an existing placement between tile and float, validates normalized
rectangles, and keeps the tiling tree consistent. It neither knows pointer
events nor grants leases, so the same commands work from a menu, IPC, or REPL.
The core should receive an intention such as “activate this View partially”
or “focus this visible leased client,” never an X event or a pixel rectangle.
## X11 adapter
`src/x11/backend.lisp` owns the CLX connection, root selection, configured
Head rectangles, backend-owned window lookup tables, and the event loop. It
does not decide layouts or issue rendering effects directly.
`src/x11/resilience.lisp` owns the narrow X error policy, bounded diagnostic
reports, independent input-grab cleanup, and explicit recovery reconciliation.
Only request-specific vanished external-client/unviewable-focus races are
tolerated; BadAccess and Lime-owned resource failures remain errors. Failed
synchronous client queries unwind to an event/adoption boundary rather than
waiting for a reply that cannot arrive. Dispatch consumes each current event
before mutation (CLX otherwise retains it on an unwind), and returns after one
event so a busy client cannot starve developer control requests.
The separate `lime/dev` runtime catches unexpected dispatch errors with a
pre-unwind backtrace, pauses without replacing the world, releases active grabs,
and continues servicing the owner-thread mailbox and Slynk. Explicit resume
validates model/adapter consistency and reconciles live X windows before render.
It is not a rollback, automatic retry, or persistence system. Explicit backend
restart keeps the supervisor and REPL alive through success or failure, and
constructs a replacement world from session storage when enabled. A separate lifecycle lock serializes REPL
and supervisor stop/restart operations without blocking mailbox bookkeeping.
Shutdown attempts each cleanup independently
and always attempts to close CLX. See [recovery behavior](development.md#recovering-from-an-event-loop-error).
`lime/persistence` is separate from X11. `src/persistence/model.lisp` encodes a
versioned plain-data schema and reconstructs a fresh validated world, including
cached drafts; it derives leases from active selections/unavailable slots.
`src/persistence/store.lisp` owns bounded non-evaluating reads, a lifetime writer
lock, private atomic replacements, and file/directory synchronization.
`src/x11/persistence.lisp` supplies verified server/window tokens, physical-Head
reconciliation, restored-client adoption, debounced owner-thread snapshots, and
one off-thread disk writer. The worker never sees the live world or CLX. A
restore error blocks autosave rather than replacing a rejected file with a
blank desk. Configuration remains policy, not generated task state. See
[persistence boundaries](persistence.md).
`src/x11/randr.lisp` discovers active physical outputs at startup and maps one
active CRTC to one logical Head; cloned outputs share a CRTC and therefore a
Head. RandR events update only an adapter-owned topology snapshot. A refresh
may safely replace rectangles when the same Head ids still exist. For added or
removed Heads, the adapter exposes a report -> build plan -> apply plan command
sequence; it rereads RandR immediately before apply and rejects a plan if the
physical Head-id set changed while a frontend was presenting choices.
`src/x11/lifecycle.lisp` turns narrow client lifecycle events into core
commands: adopt a normal new client into the selected live Presentation,
retire a destroyed or application-withdrawn client, or ask the renderer to
restore a tiled geometry. Its pending-unmap token boundary distinguishes a
renderer-driven View hide from an application's real ICCCM withdrawal.
`src/x11/icccm.lisp` supplies the protocol details lifecycle and rendering
need: cached title/class/capability facts, `WM_STATE` normal/iconic updates,
property refreshes, graceful close requests, protocol-aware focus, and
synthetic ConfigureNotify replies. Lime removes `WM_STATE` on a real client
withdrawal. It does not change Activities or lease planning.
`src/x11/placement-policy.lisp` consumes a managed client's transient-parent
fact only at adoption: a `WM_TRANSIENT_FOR` child follows its parent’s active
Presentation and Head, starts as a centered float, and receives focus.
Dialog-like EWMH clients float on the selected Head. Ordinary clients whose
`WM_NORMAL_HINTS` declare equal positive minimum/maximum sizes on both axes
default to centered floats too, unless a matching configuration rule requests
tiling. This is initial-placement policy only: later hint changes do not undo
manual floating/tiling choices or saved arrangements. Resizable main windows
remain tiled even when their fixed-size startup windows float. The adjacent
`src/x11/geometry.lisp` adapts resolved pixel rectangles to cached
`WM_NORMAL_HINTS` min/max/base/increment constraints inside the target Head.
Both modules are adapter policy; they never write View or Lease state.
`src/x11/appearance.lisp` owns configurable client borders and inner/outer
tile gaps. It insets resolved slots without editing the layout tree, contains
the complete bordered frame, and applies normal hints within each tile's
remaining space. Floats bypass gaps but retain borders. The render planner
counts leased tiles with resolved geometry separately on each Head; a sole
visible tile bypasses gaps and borders in every layout. This also covers
monocle without a layout-name special case. Normal hints and the bar's work
area still apply. Fullscreen
bypasses all decoration, normal hints, and the bar reservation.
The adapter caches allocated border colors and original application border
widths, restoring widths on withdrawal or shutdown. Configuration reloads
repaint the same draft rather than turning theme choices into saved View data.
`src/x11/render.lisp` has two separate jobs:
1. `build-render-plan` derives inspectable `x11-effect` data from active
Presentations—currently configure, map, unmap, float raise, and one
input-focus effect. Configure effects include content geometry, border
width, and the color chosen for global client focus.
2. `execute-render-plan` performs that plan through CLX and flushes it as one
batch. It rejects a plan if the core world revision changed after planning.
The temporary `apply-active-presentation` name remains as a small convenience
wrapper around those two operations. It then redraws the adapter-owned Head
bars; that UI state is deliberately outside the model render plan. Normal
layouts and floats resolve against `x11-head-work-area` (below a configured
top-bar reservation), while EWMH fullscreen still resolves against the whole
physical Head. New policy-aware callers can inspect a plan before executing
it.
`src/x11/commands.lisp` is the frontend-neutral imperative API. A keybinding,
menu, REPL, or future IPC service calls it to choose an already-tested core
transition and request one reconciliation. It also owns Head-relative
selection and focused-client transfer: the target draft retains any existing
placement, otherwise the source tile/float placement is copied, and the core
performs the exclusive lease move. Private clients require the same Activity;
shared clients may transfer across Activities without changing their home.
The interactive `backend-send-focused-client-*` entry points live in `ui.lisp`:
eligible transfers run immediately, while a private cross-Activity send offers
a named confirmation with Cancel selected. It captures the window, both live
arrangements, focused Head, and world revision, rejecting stale choices.
Confirmation calls the separate `backend-rehome-client-to-head` command and
core `move-client-to-presentation` transaction: membership, old-checkpoint
pruning, new placement, and the single lease commit together after validation.
Neither monitor switches Activities. After a successful send, keyboard focus
and command/new-window routing follow the moved window to the destination Head;
the source remembers a surviving client for when the user returns. Cancelled or
rejected sends do not change focus.
`backend-move-client-to-activity` explicitly rehomes a visible client and queues
it in the destination Activity's draft for the same Head without switching.
`backend-set-client-shared` enables sharing without moving the window; NIL calls
`backend-make-client-private`, which keeps it private to the current Activity.
That explicit command also accepts a Head and expected revision. The menu names
the Activity, defaults to Cancel, and rejects changed focus/desk/revision before
calling it. The hidden-window picker includes shared guests and asks before
transferring an existing lease.
`src/x11/input.lisp` only turns a
passive X key grab into such a command; it contains no View names or lease
rules. Passive grabs synchronously queue subsequent keyboard input until the
handler either opens an asynchronous palette grab or finishes; an unwind-protect
always thaws input, including failed or missing handlers. This closes the gap
where releasing the shortcut before a slow menu opened sent typed letters to
the previously focused application. The pointer remains asynchronous.
`src/x11/default-bindings.lisp` supplies the default, replaceable
dwm-shaped starter map plus named Activity/View/Head configuration helpers; it
owns neither X events nor model policy. `src/x11/pointer.lisp` does the
equivalent minimal routing for unmodified Button1 plus Super+Button1/2/3
float move/toggle/resize gestures. It uses passive grabs so
a click in an application's child window reaches the managed client boundary;
an ordinary managed-client click resolves to its leased Head, focuses that
client, then replays the click to the application. A Super gesture focuses a
floating client and turns its root-pixel motion into a normalized geometry
command. Consecutive queued motions from the same gesture are coalesced in
bounded batches; key/button/lifecycle events are never skipped. Release applies
its final coordinates, and a gesture is cancelled if its original Presentation
or floating lease is no longer current. Geometry-only commands configure just
that client through the usual layout/border/normal-hint calculations, without
reapplying focus, stacking, EWMH inventories, or other clients' rectangles.
Only the clean-to-dirty transition redraws bars. Blank root space selects that
Head and deliberately focuses root.
The adapter owns reusable `left_ptr` and `hand2` cursors. Bars and dropdowns
inherit the root arrow except over their measured clickable regions, where
only that UI window selects the hand. Application-specific cursors are
untouched. Enter/motion/leave events update the cursor; redraws also recheck
the pointer so changed widgets, filtered lists, and resized menus cannot
leave a stale hand or highlight under a stationary mouse. This is event-driven,
not a pointer polling timer. Root selection is confirmed before installing
the arrow, and shutdown releases both cursor resources.
`src/x11/cursor.lisp` owns cursor configuration, assets, and resource lifetime;
`pointer.lisp` only routes gestures and selects arrow/hand roles. Optional
libXcursor loads theme images into memory, including inherited/default themes.
CLX uploads their ARGB pixels through X Render on the existing WM connection,
independent of root visual depth. There is no additional Xlib connection or
GTK dependency. Temporary images, pixmaps, GCs, pictures, and animation frames
are released after upload; the installed cursor set lives until replacement
or shutdown. Theme/size reload is transactional with the rest of configuration:
old cursors remain live until commit, and rollback rebinds the original XIDs,
including on hovered UI. Unsupported/missing theme facilities use core cursors
with diagnostics available through `backend-cursor-report`.
`src/x11/session-settings.lisp` is the application-facing settings boundary,
separate from cursor rendering. Explicit cursor config publishes only
`Gtk/CursorThemeName` / `Gtk/CursorThemeSize` using the
[XSETTINGS protocol](https://specifications.freedesktop.org/xsettings/latest/).
It claims `_XSETTINGS_Sn` only when vacant, announces a complete initial property
with a real server timestamp, preserves unrelated queued events, and destroys
its owner window on SelectionClear without stealing ownership back. Existing
managers remain authoritative for their toolkit clients.
The same layer owns reversible cursor-only RESOURCE_MANAGER edits and a child
launch-environment overlay. It preserves unrelated resource records and later
external edits; resolving NIL defaults excludes Lime's own publication. No
desktop config files, process-global environment, or activation environment
are mutated. Configuration failure restores prior publication; shutdown
releases it before closing X. Runtime ownership lives in a backend-keyed table,
not the pure model or session persistence. Clients may cache settings; protocol
publication cannot force an application to replace its own cursor resources.
`src/x11/config.lisp` is a user-facing adapter-policy layer, exposed as
`lime.config`. Its `DEFCONFIGURATION` macro produces ordinary data:
first-class commands, bindings to those commands, ordered initial window
rules, window appearance, optional bar settings, and startup hooks. The configuration is held by
the backend, not the core world. A named command appears in direct palette
search and, when it has a key letter, under the palette’s User branch as well
as being eligible for a physical binding. Reload validates all data before it
touches X11 and rolls back binding/bar/appearance settings after an installation error; it
never recreates Activities, Views, Presentations, leases, or Viewsets. Current
rules style only ordinary new clients (float/center) and intentionally cannot
make a class match mount a View or move a client between task contexts.
`src/x11/navigation.lisp` owns contiguous Activity positions, stable Activity-local View slots,
and last-used Presentation ids per `(Head, Activity)`. A separately owned,
backend-keyed runtime avoids changing core identities or the live backend's
structure; shutdown releases it (SBCL also uses weak keys). Rendering records
active arrangements once per model revision. Slot reassignment changes only
navigation metadata, whose own generation participates in session autosave.
`src/x11/activity-navigation.lisp` owns persistent portable/extended navigation
intent and the portable arrangement id. New named Activities are portable;
the bootstrap Scratch desk starts with its existing per-Head blank portions.
`src/presentation-navigation.lisp` supplies the pure atomic bring/swap operation:
it preserves active lease identity, transposes displaced cached portions, and
preflights inactive resumes without replaying missing checkpoint slots.
Portable checkpoint recall previews the compound move/recall on a detached
world before any live mutation, so a lease conflict or cancellation never
swaps monitors as a side effect. Explicit multi-Head Viewsets and direct
multi-portion model edits remain capable of extending an Activity.
The footprint sidecar has its own autosave generation; it is not a slot added
to live backend or model structures. `activity-navigation-ui.lisp` exposes
extension and return-to-portable commands through the normal dropdown, with
cancel-first choices and stale-context guards.
Activity deletion closes gaps while preserving surviving bar order; new
Activities append. Persisted sparse Activity slots are validated before being
compacted. Explicit Activity slot assignment swaps within the existing count,
whereas View assignments retain their stable, gap-filling policy.
The versioned X11 session envelope stores these indexes separately from the
pure world. The core's unique `(Activity, Head)` arrangement is authoritative;
last-used ids remain migration metadata, not a separate navigation target.
Activity navigation resumes current locations without a collision menu.
Explicit checkpoint recall still uses lease preflight/choice commands. `:navigation-keys` expands
to ordinary configuration commands/bindings rather than hardcoded key policy.
`src/viewset.lisp` adds the corresponding grouped core planner for named
whole-desk shortcuts. It records only `Head -> View` entries, preflights every
target Head as one outgoing group, and commits one atomic multi-Head lease
transition. `src/x11/commands.lisp` exposes save/preflight/strict/partial/take
commands; no X11 frontend is allowed to recreate that grouped collision logic.
## UI boundary
`src/ui/session.lisp` is a renderer-neutral direct-search, one-string-prompt,
and key-tree session. It owns a candidate list, filtering, selection,
minibuffer text, a key-path stack/page, and declarative results (`:close`,
`:keep-open`, `:replace`, or `:prompt`). Candidate actions and key nodes are
supplied by a frontend, which makes a collision prompt an ordinary replacement
candidate set, a name prompt an ordinary session transition, and a which-key
menu a data tree rather than special modes embedded in a View-switch command.
`MAKE-UI-SESSION` explicitly selects `:search` (the default for standalone
pickers) or `:key-path` (the Super-y menu). A separate root candidate catalog
survives scoped/collision pickers: menu → search always restores the complete
catalog rather than accidentally searching the last replacement list.
`src/x11/bar.lisp` supplies the first reusable persistent UI surface: one
override-redirect bar per Head. It renders Activity names (not checkpoint or dirty
state), the Head-local layout token, and a focused-client title section whose
colors identify Head focus. Its optional right-side providers return `HEAD-BAR-SEGMENT` values
and may give each segment a click action; music, volume, and clock integration
therefore remain configuration rather than model state. The `Lime` segment
opens the palette and the layout segment cycles the local layout. The Activity
strip is slot-ordered on every Head; that Head's active entry uses the accent
foreground. Saved Views live in the recall/save/update menu; updates choose a
named target and confirm replacement rather than infer it from the last recall.
Each independently shaped run
owns its click region, reusing ordinary Activity resume and lease prompts.
Budgeted overflow retains the active entry and nearby slots when possible,
with a dedicated `…` target opening an Activity-only picker. Neither clipping
nor clicking invents new layouts, changes membership, or takes leases silently.
`src/x11/status.lisp` owns opt-in managed provider lifecycle and cached data,
separate from `bar.lisp` painting and `status-desktop.lisp` source protocols.
Each managed provider has one cancellable worker per backend, not per Head.
Workers publish changed data snapshots under a lock and send one coalesced
`_LIME_STATUS_CHANGED` event through a separate, serialized CLX notification
connection to an unmapped adapter-owned window. They never use the WM's X
connection or access model state. The ordinary X event loop consumes the wake
and redraws bars on their owning thread—even when there has been no keyboard,
mouse, or client event. No status refresh timer is attached to bar rendering.
Music publishes playback/title/source facts, volume publishes percentage/mute/
sink facts, and the clock publishes local date fields. A configuration-supplied
`:formatter` consumes a defensive copy on the X thread, outside worker locks,
and returns text, NIL, or a `HEAD-BAR-SEGMENT` with optional foreground, width,
and click overrides. `status-format.lisp` contains optional, parameterized
presentation helpers, not subscription logic. Config therefore chooses icons,
spacing, date style, visibility, and colors without editing protocol code.
Legacy generic providers may still publish strings through an identity
formatter. Formatting failures hide one widget without stopping its source or
other widgets. The bar's overall left/title/right geometry remains renderer
policy; this seam does not claim an arbitrary bar-layout language.
MPD snapshots and `idle` use the same native connection, retaining events
between query and subscription. MPRIS uses the documented
[playerctl follow interface](https://github.com/altdesktop/playerctl#following-changes);
audio uses a persistent `pactl subscribe` process and bounded one-shot reads.
The clock alone schedules a wall-time deadline, recomputed for each minute.
See the [MPD idle protocol](https://mpd.readthedocs.io/en/stable/protocol.html#idle)
for the accumulated-event guarantee. Reconnection timers use 1–30 second
backoff only after service failure; they are not a healthy-state polling loop.
Configuration validation accepts managed sources alongside legacy pure
provider functions. Reconciliation keeps identical provider objects, cancels
removed workers, and starts replacements; rollback reinstalls the previous
set. Shutdown wakes sleeping workers, shuts down owned MPD sockets, and kills
and reaps only owned child helpers. Provider failures are retained in
`backend-status-provider-report`, not allowed to kill the X loop or display
stale cached data. Public worker functions must cooperate with cancellation.
Bar text is shaped and measured by the shared Pango/Cairo font layer, with a
bounded per-font metrics cache. A server-free text-layout helper computes pixel-budgeted truncation and
positions, shared by drawing and click regions. Identity and layout fields
use measured ` | ` separators; the title is left-aligned inside an expanding
section with two muted one-pixel edges, and status text is right-aligned.
Validated `:padding-x` controls matching outer text margins and title insets;
the separator's own space supplies the gap after `Lime`, without duplicate
logo padding. The same layout data positions paint and click targets. A font-derived optical
baseline centers a stable letter-body reference instead of unused line leading.
Every bar field uses that baseline, independent of its current text or fallback
font's ascent, avoiding title-content-dependent vertical jumps.
`HEAD-BAR-STYLE` is validated configuration, separate from window appearance
and layout. Each bar caches its own colormap allocations and releases unused
colors on redraw/configuration rollback and shutdown. Oversized status text is truncated while
preserving room for the layout control and a small Activity navigation area.
Ordinary separators and status truncation dots do
not acquire status click actions. Each provider is shaped independently so
bidirectional text cannot reorder adjacent widgets; click regions use the
shaped source text's visual ranges, not an assumed character advance.
`src/x11/attention.lisp` owns a backend-only, two-source request cache for
ICCCM urgency and EWMH demands-attention. Property/client-message events update
it without touching the world revision. The renderer's global input target and
normal FocusIn events acknowledge only a selected visible client with verified
X focus; another Head's local focus, root focus, or a palette grab is not enough.
The cache is not serialized: adoption rereads surviving clients' protocol state.
The bar renders a configurable global count using its existing status-segment
and click machinery; the picker consumes cached data and calls
`backend-attend-client`. Visiting an Activity resumes its current arrangement
without restoring stale claims for unrelated windows.
See [attention](attention.md) for the protocol and interaction boundaries.
The cached client title prefers EWMH `_NET_WM_NAME` decoded with Babel over
ICCCM `WM_NAME`. Title/class property events repaint bars without touching
geometry, leases, or draft dirty state. `src/x11/text-native.lisp` contains
the narrow CFFI boundary to Pango, Cairo, and GLib. `src/x11/text.lisp` owns
font descriptions, shaping/measurement, grapheme-safe fitting, clipped
painting, and pixel upload for both bars and the palette. Pango handles
Fontconfig fallback and shaping, including color emoji with installed fonts.
Cairo paints a complete opaque RGB frame in memory; CLX uploads it on the
existing WM connection. The font renderer needs no extra Xlib connection or
toolkit event loop (the optional status notifier described above is separate).
The adapter requires a TrueColor root visual and converts RGB pixels to its
actual masks (including 16-bit displays). The pure model and `lime/ui`
remain independent of these native libraries.
Each UI surface owns a font/layout and a resize-reused canvas, operated only
by the backend thread. Font replacement allocates before freeing the old
resource; close, Head removal, resize, rollback, and shutdown release native
resources explicitly. `UI-FONT-STYLE` is validated configuration, shared by
both surfaces. A font reload repaints/resizes an open palette without replacing
its search session. This font-rendering boundary does not add an IME.
`src/x11/ui.lisp` builds the command catalog/tree and owns keyboard-grab/input
routing. `src/x11/ui-render.lisp` owns the temporary X11 surface, fonts,
canvases, measured geometry, and painting. Its server-free grid projection
chooses up to four columns, fits labels in pixel budgets, and pages large
trees within the Head-local dropdown. The renderer clamps the session's
requested page when font/Head dimensions change. Height follows the current
menu or picker instead of reserving a fixed ten-row block. Keys, submenu
labels, breadcrumb, and footer have distinct visual roles; detailed candidate
descriptions appear only for the selected search result. The dropdown reuses
the configured bar colors through the adapter's style seam, rather than
hard-coding black/white. It draws directly below the persistent bar. Its Activity and
View choices call the command layer. A collision first performs the normal
read-only preflight, then offers the already-public strict, partial, and take
commands explicitly. The overlay is frontend state only: no X11 window,
keyboard grab, search query, or visual styling leaks into `lime.core`.
The same replacement-candidate mechanism drives RandR reconciliation. A
pending added/removed-Head snapshot contributes one direct-search candidate
and a `Head -> topology` key-path entry. The frontend collects one move or
suspend choice for every removed live Presentation, shows a final Apply choice,
then calls the separate RandR build/apply commands. It never reacts directly
to an output event or invents a second modal UI protocol.
Super-y and the bar logo open the key root; Super-p remains the separate fuzzy
program picker. Executable discovery is cached in the launcher adapter, keyed
by PATH, current directory, and PATH-directory modification dates. A five-minute
maximum cache age catches permission-only/coarse-timestamp changes on the next
open; no timer polls while idle. Name deduplication uses a hash table rather
than quadratic list comparisons. Each palette gets fresh candidates/actions;
the cache holds only executable names, not a stale backend or UI session.
Space (unless explicitly bound in a custom tree) or Tab opens
global search; Tab returns to the root map. Prompts retain literal spaces and
cannot switch modes with Tab. Backspace goes up a prefix and Escape dismisses
the entire overlay without dispatching another command. Key cells and search
results are also clickable, with hover feedback and small Back/Search/Menu/
Confirm/Close controls as appropriate. Wheel input navigates menu pages or
search selection; paged grids also expose previous/next buttons. Geometry is
shared by painting and clipped hit regions, rebuilt each frame. Mouse events
on the overlay are consumed before client focus/drag routing, including clicks
in blank space. No global pointer grab is needed. Both input paths share the
same session transitions and error boundary; candidate clicks carry the actual
candidate object and reject stale choices instead of reusing a list index.
Richer text input, dynamic workflow-specific trees, themes, and decorations
can reuse the same session protocol or add another
renderer without changing Activity/View/Lease rules.
## Direction of dependencies
Dependencies should point inward toward the core, never the reverse:
```text
input / pointer / menu / IPC -> commands -> lime.core
user configuration -> commands + input + bar + lifecycle policy
ui session -> commands -> lime.core
x11 bar / UI renderer -> ui session + CLX + adapter snapshots
lifecycle -> lime.core
icccm -> CLX
ewmh -> commands + CLX + active Lease queries
randr -> CLX + backend Head rectangles + core topology plans
render -> lime.core + ICCCM/EWMH + CLX
backend -> CLX + module interfaces
```
That leaves room for an IPC frontend, richer Viewset editing, an interactive RandR
topology picker, remaining ICCCM/EWMH work, richer pointer policy, and
alternate layout engines without forcing the Activity/View/Lease model to know
about physical output APIs.

78
docs/attention.md Normal file
View File

@@ -0,0 +1,78 @@
# Application attention
Lime watches ICCCM `WM_HINTS` UrgencyHint and EWMH
`_NET_WM_STATE_DEMANDS_ATTENTION`. A request adds a quiet, clickable `! N`
badge to every visible Head bar. There are no flashes, popups, timers, or
automatic Activity switches. A window requesting both hints counts once.
Click the badge or use **Super-y → w → a** for a searchable list across all
Activities. The direct command search also offers “Windows needing attention”.
Entries show the Activity and the current application title. The ordinary
hidden-window picker marks requesting windows with `!`, and Activity search
shows request counts in its details.
## Visiting a request
- A leased window is focused on the Head that already owns it. It is not
pulled onto the Head where you opened the picker. This also works for a
non-visible member of a monocle stack.
- A hidden window is shown in its own Activity on the selected Head. If
necessary, Lime resumes that Activity's **draft**, not an arbitrary saved View.
- Resuming a draft with contested windows still offers explicit partial/take
choices. Opening or cancelling that choice does not change either Head.
The target and its lease are checked again when you select an option.
- Visiting a window behind a different fullscreen window exits that fullscreen
state so the requested window can actually be seen. Visiting the fullscreen
window itself preserves fullscreen.
- Revealing a hidden window edits the live draft normally; it never rewrites
a checkpoint. Merely receiving or displaying attention changes no model state.
Lime acknowledges attention when the selected, visible input target actually
has X focus, including focus inside a child widget. Another Head's saved focus,
root/background focus, and an open command-palette grab do not count. Clients
using `WM_TAKE_FOCUS` keep their request until they actually answer by taking
focus. Closing the menu after a visit then acknowledges the chosen application.
Acknowledgement clears only the urgency flag and the demands-attention atom;
other `WM_HINTS` fields, fullscreen, and unrelated EWMH state remain intact.
Applications can cancel either source independently. Destruction and genuine
withdrawal remove the client from the list; stale picker entries cannot revive it.
## Boundaries
This is **application attention**, not a desktop notification daemon or an
unread-message counter. Applications must publish the relevant X11 hints.
A game's fullscreen Head still hides its bar; another visible Head can show
the badge, and the menu remains accessible. Disabling the badge or the entire
bar does not disable the picker.
Attention is adapter-local, never part of session snapshots. Surviving X
windows have their current hints reread when adopted after a WM restart.
Dead windows and old alerts are not resurrected from disk. Hidden
`_NET_ACTIVE_WINDOW` requests which Lime declines become attention requests;
the existing activation policy for already leased clients is otherwise unchanged.
Configuration controls the badge's color, marker, and visibility; see
[bar appearance](configuration.md#bar-appearance). No personal configuration
changes are required for the default badge/menu. This addition changes backend
structure and ASDF components: use a fresh Lime process to load it, rather than
a function-only hot reload. Your running session is not modified by installing
the code.
## Implementation and tests
`src/x11/attention.lisp` owns the live source cache and acknowledgement.
ICCCM/EWMH adoption and property/client-message handlers feed it; focus effects
and normal FocusIn events acknowledge it. Focus queries run only for pending
requests, not as an idle poll. Bar/UI code consumes cached queries; the command
layer owns visiting and the ordinary Activity/lease transitions.
The owned-Xvfb smoke test is `test/x11-attention-smoke.lisp`. It covers real
ICCCM/EWMH changes and cancellation, both message atom slots, initial hints,
NoInput and asynchronous child focus, hidden and inactive Activities, bar clicks,
which-key visits, independent Heads, fullscreen preservation, stale selections,
and withdrawal/destruction. Server-free tests also cover partial/take collisions,
stale choices, checkpoint isolation, and configurable presentation.
Protocol references: [ICCCM WM_HINTS](https://www.x.org/releases/current/doc/xorg-docs/icccm/icccm.html)
and [EWMH window state](https://specifications.freedesktop.org/wm/1.5/ar01s05.html).

674
docs/configuration.md Normal file
View File

@@ -0,0 +1,674 @@
# Configuration
Lime’s configuration is a user-owned Common Lisp file—not a source-tree fork
and not a collection of patches. Load `lime/x11`, then start through
`lime.config:start-lime` with the file path you want to own, for example
`~/.config/lime/config.lisp`.
```lisp
;;;; ~/.config/lime/config.lisp
(in-package #:cl-user)
(lime.config:defconfiguration my-lime
(:options :default-keybindings t
:default-modifiers '(:mod-4)
:default-layout :master-stack
;; New tiles append at the bottom of master/stack, but receive
;; focus; closing one returns focus toward master.
:new-client-focus t
:new-tiled-client-position :back
:close-focus-direction :previous)
(:command terminal
:label "Terminal"
:description "launch Alacritty"
:category "Launch"
:key #\t
:action (lambda (backend)
(declare (ignore backend))
(uiop:launch-program '("alacritty") :output :interactive)))
(:command reload-config
:label "Reload configuration"
:description "reload this file without restarting Lime"
:key #\r
:action #'lime.config:reload-configuration)
(:command palette
:label "Command palette"
:description "open Lime's command picker"
:action #'lime.x11:backend-open-command-palette)
;; This replaces the starter map's Super-t binding with TERMINAL.
(:bind #\t terminal :modifiers '(:mod-4))
(:bind #\y palette :modifiers '(:mod-4))
(:bind #\r reload-config :modifiers '(:mod-4 :shift))
;; Rules are ordered. The first match styles an ordinary newly mapped
;; client; strings are case-insensitive exact ICCCM WM_CLASS matches.
(:rule :class "mpv" :float t :center t)
(:rule :class "Pavucontrol" :float t :center t)
(:font :family "monospace" :size 14 :weight :normal)
(:bar :height 22
:status-providers (list (lime.x11:make-clock-status-provider)))
(:appearance :border-width 3
:focused-border "#ffac54" :unfocused-border "#121f25"
:inner-gap 5 :outer-gap 5))
```
```lisp
(asdf:load-system "lime/x11")
(lime.config:start-lime
:display-name ":99"
:config-file #P"/home/adan/.config/lime/config.lisp")
```
`Super-y`, the clickable `Lime` bar item, and a command’s optional
`:key` all meet in the same command registry. `Super-p` is deliberately kept
for the immediate dmenu_run-like PATH-program picker. `:key` places a command under the
palette’s `User` which-key branch; it does not make an X key grab by itself.
Use `:bind` for that. Commands are ordinary functions, so anything beyond this
small DSL is just Common Lisp calling Lime's public command functions.
The program picker caches PATH executable names. It refreshes when PATH or a
directory modification date changes, or after five minutes on the next open
(covering executable-permission edits). For immediate rediscovery from the REPL,
use `(lime.x11::%path-program-names :refresh-p t)`. Opening shortcuts queue rapid
follow-up keystrokes safely until the menu takes over, including cold launches.
## Numbered navigation
The starter map binds `Super-1` through `Super-9`, then `Super-0`, to Activity
positions 1–10 in the Activity bar order. Add Shift to select View slots 1–10
**inside the current Activity**.
For a complete personal keymap with starter bindings disabled, opt in with:
```lisp
(:navigation-keys)
;; Or use another modifier family:
;; (:navigation-keys :modifiers '(:mod-1))
```
This clause expands into ordinary named commands and bindings. It inherits
`:default-modifiers` unless explicitly overridden. For individual custom keys,
commands can call `backend-select-activity-slot` or `backend-select-view-slot`.
The clause uses command ids `:navigate-activity-1` … `:navigate-activity-10`
and `:navigate-view-1` … `:navigate-view-10`.
Numbers are durable navigation metadata, not identities or a ten-Activity
limit. The bar shows Activity names only, such as `Work`; the picker retains each
Activity/View's number for reference. The bar lists Activities in slot order.
Saved Views are arrangements to recall, not subworkspaces: no View name,
Draft label, or dirty marker appears in the bar. Active text
uses the configured `:accent-color` (falling back to `:focused-foreground`);
inactive text uses `:foreground`, without changing background colors.
Click a name to use the same portable/extended navigation as the number keys.
Clicking the already active name restores client
focus without resetting its arrangement. Long lists retain the active tab
where space permits and use a clickable `…` Activity picker for overflow;
title and status areas remain bounded. Custom commands can open the same
picker with `backend-open-activity-picker`.
Activity numbers are contiguous positions: deleting an Activity shifts later
ones left, and new Activities append to the end. Existing order is otherwise
preserved, including across restarts; older sparse assignments are compacted
in their existing bar order. No Activity identity, window, View, or lease changes.
On narrow bars, the `…` picker shows the full order, including clipped tabs.
Drafts have no View number. View slots remain stable and local: new Views take
the lowest free slot and deleting one does not renumber the others. Previously
unnumbered sessions start with Scratch on 1, then Activities alphabetically;
existing Views use library order. Beyond position/slot 10, use name search or
reassign a shortcut. Empty shortcuts do nothing. Session persistence saves
navigation metadata across restarts; without it, the order lasts for this runtime.
New Activities are portable: selection brings their current arrangement here,
including unsaved edits. If already displayed elsewhere, the two monitors swap
arrangements; an inactive Activity changes only the selected monitor. Numbered selection of the current Activity
or View is a no-op, not a reset. Selecting another View recalls its checkpoint.
The menu's explicit `Recall view...` restores a checkpoint even when it was the
last one recalled. For portable Activities, recalling a checkpoint on another
monitor first brings that Activity's arrangement there; lease conflicts are
checked before either step, so cancelling does not swap monitors. A View still
cannot be mounted twice. Extended Activities retain independent Head-local
recall and explicit partial/take lease choices.
Both `backend-activate-activity` and `backend-switch-to-activity` use the same
current working arrangement. Activity navigation never reloads a checkpoint
or retries an old window claim. Moving a window updates its previous live
location even while that Activity is undisplayed. Hidden windows remain hidden
when an arrangement travels; ordinary navigation never gathers the whole pool.
`Super-y → a → e` chooses a monitor to **extend** the current Activity onto.
Its existing portion stays put; a new portion is empty, or an existing portion
resumes. That Activity then navigates independently per Head, even when one
portion becomes empty. `Super-y → a → p` makes the current portion portable
again once other portions are empty and inactive; it preserves saved Views
and hidden windows. Both actions are also searchable commands.
Noninteractive callers use `backend-extend-activity-to-head` and
`backend-make-activity-portable`; `backend-activity-navigation-mode` reports
`:portable` or `:extended`. The startup Scratch desk retains its initial blank
per-Head portions; newly created named Activities default to portable.
Sending a window to a portable Activity targets its real arrangement, even on
another monitor. It appears there if that arrangement is active, otherwise it
is queued there; focus stays with the sending monitor. Extended destinations
still use the sending monitor's portion.
`Super-y → v` contains saved-arrangement actions: `s` saves a new named
checkpoint, `o` recalls one, and `u` chooses a saved view in the current Activity
to replace. Updating requires a named confirmation (Cancel is selected first)
and rejects stale choices if the desk changes. The starter `Super-Shift-s`
binding opens this same picker via `backend-open-view-update-picker`. Advanced
Lisp callers can still explicitly use `backend-update-view` or
`backend-update-current-view`; the UI no longer silently targets the last recall.
For interactive monitor sends, bind a command calling
`(lime.x11:backend-send-focused-client-relatively backend :next)` (the starter
`Super-Shift-e` binding). It transfers same-Activity/shared windows immediately
and opens a Cancel-first confirmation for a private cross-Activity move.
Successful sends follow the window with keyboard focus and route subsequent
commands/new windows to its destination Head. Cancelling leaves focus unchanged.
The same entry point works inside configured menu commands. Strict non-prompting
`backend-transfer-focused-client-*` APIs still reject private cross-Activity
transfers; they never silently change home membership.
Shortcuts can be reassigned through the REPL; occupied assignments swap.
Activity positions must be within 1 through the Activity count, so reordering
cannot create a gap. View slots may still be any positive integer:
```lisp
(lime.dev:with-development-backend (backend)
(lime.x11:backend-set-activity-slot backend "work" 2)
(lime.x11:backend-set-view-slot backend "work/docs" 3)
(lime.x11:backend-navigation-slots backend))
```
## Browser launch profiles
The built-in Waterfox command and the PATH picker use the browser's normal
profile selection, like running `waterfox` in a terminal. Testing alongside
another graphical session can explicitly opt into an isolated profile:
```lisp
(lime.x11:backend-launch-browser backend :test-profile-p t)
```
That test profile lives in Lime's XDG state directory (`lime/waterfox-profile/`)
and intentionally has separate tabs, settings, and extensions. It is never
selected implicitly for normal desktop use.
Super-y opens the compact key map first: `w → +Windows` enters the Window
group, for example. Space or Tab opens command search, Backspace goes up a
menu level, and Escape closes. Tab in search returns to the root map. Large
User groups can be paged with PgUp/PgDn. The dropdown uses the bar's configured
background, foreground, focus, separator, and edge colors, plus the shared
font below; it no longer has a separate hard-coded black/white appearance.
## Configuration versus session state
Your configuration defines policy and commands. Activities, saved Views,
Viewsets, and drafts are automatically stored separately when using `lime/dev`.
Choose `:session-file :default`, a pathname, or `nil` on the session launcher;
the lower-level `lime.config:start-lime` defaults to no session persistence.
Reloading this configuration does not reload or replace the desk. See the
[persistence guide](persistence.md), including the distinction between restarting
Lime and relaunching applications after logout.
## Status widgets
Opt into managed providers in your own configuration; no music/audio services
are started by Lime's starter configuration:
```lisp
(:bar :height 22
:status-providers
(list (lime.x11:make-music-status-provider
:source :mpd :sources '(:mpd "spotify")
:mpd-host "127.0.0.1" :mpd-port 6600 :maximum-width 320
:formatter (lambda (data)
(lime.x11:format-music-status data
:playing-icon "" :paused-icon "" :icon-gap " ")))
(lime.x11:make-volume-status-provider
:foreground nil ; inherit the bar's color, or set "#RRGGBB"
:formatter (lambda (data)
(lime.x11:format-volume-status data
:icon "" :muted-icon "" :icon-gap " ")))
(lime.x11:make-clock-status-provider
:formatter (lambda (data)
(lime.x11:format-clock-status data
:icon "" :icon-gap " " :twenty-four-hour-p nil)))))
```
Subscriptions publish **data**, not display strings. The configuration owns
the ordered provider list, formatters, icons, gaps, optional per-widget colors,
width limits, and click actions. Rendering still owns the overall left/title/
right bar geometry; this is not yet an arbitrary bar-layout DSL.
Each `:formatter` is a fast function of one cached data plist:
| Provider | Snapshot fields |
| --- | --- |
| Music | `:state` (`:playing`, `:paused`, `:stopped`, `:unknown`), `:title` (string/NIL), `:source` (`:mpd` or MPRIS player name) |
| Volume | `:volume` (first-channel integer percentage), `:muted-p`, `:sink` (output name) |
| Clock | `:universal-time`, `:minute`, `:hour` (0–23), `:day`, `:month`, `:year`, `:weekday` (Monday=0), `:daylight-p`, `:timezone` (CL hours west of GMT) |
The clock publishes at minute resolution, even if a custom formatter decodes
seconds from the initial timestamp. NIL source data means unavailable: the
widget is hidden and its formatter is not called until data arrives.
A formatter may return a string, NIL to hide available data, or a
`head-bar-segment`. For example, this configuration can color muted volume
differently without making the source know anything about themes:
```lisp
:formatter
(lambda (data)
(lime.x11:make-head-bar-segment
:text (lime.x11:format-volume-status data :icon-gap " ")
:foreground (if (getf data :muted-p) "#9f9584" "#d8c9ad")))
```
Segment foreground/width/click overrides take precedence over the provider's
defaults; NIL fields inherit those defaults. Provider `:foreground nil`
inherits the bar foreground. Colors must be `#RRGGBB`. Invalid formatter
output or a formatter error hides only that provider and records a diagnostic;
other widgets and subscriptions keep running. Formatters run on the X thread,
outside worker locks, on a defensive copy. They must not block or do I/O.
The `format-*-status` functions are optional presentation helpers in
`src/x11/status-format.lisp`, not part of the subscription protocols. Supply
your own formatter for a different date format, stopped-track display, labels,
or icons (NIL/empty icons omit both icon and gap). Helpers default to one space
for music and two for volume/date. `:twenty-four-hour-p` now belongs to
`format-clock-status`, not `make-clock-status-provider`.
Pass `:icon-foreground "#76929f"` to any of these helpers for a colored icon
with an independently colored label. With this option the helper returns a
`head-bar-segment` instead of a string; the label still inherits the provider
or bar foreground. Without it, the existing string/NIL behavior is unchanged.
For example:
```lisp
:formatter (lambda (data)
(lime.x11:format-volume-status data
:icon-foreground "#76929f" :icon-gap " "))
```
Custom segments can use `:prefix-length` (Lisp character count, not UTF-8
bytes) and `:prefix-foreground` for the same effect. The icon and label remain
one shaped item and one click target; fitting never colors truncation dots as
part of the icon. Text is still plain text, not markup.
Music uses a native MPD connection with `idle player playlist`, or a long-lived
`playerctl --follow` process for an MPRIS player name such as `"spotify"` or
`"brave"`. Default formatting hides stopped players; a custom formatter can
show their retained data. Disconnected players disappear. Paused tracks retain
their pause indicator. The song's pixel budget truncates at grapheme boundaries,
leaving room for the following widgets. Volume uses `pactl subscribe` and
queries the default output only initially and on relevant events. It follows
mute, volume, output changes, and hotplug; the first channel percentage matches
the old dwm script. Queued bursts are coalesced without a debounce delay.
The clock shows local date/time and recomputes the next wall-clock minute
boundary after every wake, not a repeating sleep measured from startup.
It is a date/time widget, not a calendar/appointment application.
The external event bridges need `pactl` (PulseAudio or PipeWire-Pulse) and
`playerctl` (only when using MPRIS); MPD controls use `mpc`. The built-in desktop
sources currently target SBCL/Linux. MPD host/port/password are explicit
constructor options, not inferred by reading a shell script. MPRIS and audio
inherit the session's D-Bus/audio environment; a nested Xephyr session will
normally share the host user's player and volume. No controls are invoked
merely by displaying the widgets.
Click music to play/pause; click volume to toggle mute. Each constructor accepts
an optional `:click-action` function of `(backend head-id)` to customize this.
The clock has no default click action. Bind these commands through your usual
configuration command registry:
```lisp
(lime.x11:backend-cycle-music-source backend) ; :mpd -> "spotify" -> :mpd
(lime.x11:backend-music-command backend :toggle) ; also :next / :previous
```
The widget and controls share the same selected source. An optional `:id`
supports multiple music providers; commands accept the corresponding `:id`.
Reloading a newly evaluated music constructor resets its source to the
configuration's `:source`. Your dwm scripts remain independent.
Managed providers share one cached snapshot across Heads. Subscription failures
clear stale data and reconnect with bounded 1–30 second backoff. Queries have
timeouts, and reload/removal/quit cancel and join old workers, kill/reap their
owned helpers, and close sockets. Set `:status-providers nil` explicitly to
remove all providers; omitting the option preserves the previous setting.
To inspect connection errors through SLY:
```lisp
(lime.dev:with-development-backend (backend)
(lime.x11:backend-status-provider-report backend))
```
Fast plain `(backend head-id)` provider functions returning `head-bar-segment`
values still work. They run during drawing, so must not perform blocking I/O
and do not schedule their own refresh. For a Lisp-only scheduled source,
`make-status-provider` accepts `:id`, `:run`, `:click-action`, and optional
`:formatter`, `:foreground`, and `:maximum-width`. Its worker function uses
`status-publish` (scalar/string/list data, or NIL for unavailable),
`status-wait` (interruptible deadline), and `status-cancelled-p`; it must honor
cancellation, never mutate the WM/backend, and never draw from its thread.
The built-in I/O sources additionally register short resource-cancellation
callbacks through the internal runtime boundary.
Publication and formatting copy data trees, so mutating a producer's input or
a formatter's argument cannot change the worker cache. Formatters are never
called by the publisher. Generic providers default to the identity formatter,
so existing string-publishing providers continue to work. Reports expose both
`:value` (raw data) and `:text` (formatted output), plus source/formatter errors.
Changing a formatter on an existing provider can redraw the cache without
restarting its worker; evaluating new constructor objects during config reload
still replaces those providers using the normal lifecycle.
## Fonts
The bar and dropdown share one font setting, independent of window layouts:
```lisp
(:font :family "monospace" :size 14 :weight :normal)
```
These are the defaults. `:size` is a pixel size (1–96), not points; `:weight`
is `:normal` or `:bold`. Family names are installed Fontconfig font families,
not X11 core-font names or Xft patterns such as `Dina:size=11`.
Comma-separated families give a preferred fallback order, for example:
```lisp
(:font :family "Dina, Fira Code Nerd Font, Noto Color Emoji"
:size 15 :weight :bold)
```
Pango/Cairo supplies antialiasing, Unicode shaping, bidirectional text, and
system font fallback. Japanese, emoji (including color emoji), and Nerd Font
icons work when suitable fonts are installed. A missing family falls back
to a system font; a character missing from every installed font may still
show a missing-glyph box. Lime no longer converts Unicode to `?`.
Font changes reload live, including an already-open dropdown. Its rows resize
to the font; the bar keeps its explicitly configured `:height`, so increase
that too for larger fonts. Fitting uses measured glyphs and truncates only at
grapheme boundaries, preserving combining accents and joined emoji. This is
rendering support, not an input-method/IME implementation.
The first upgrade from the old core-font renderer changes native resources
and structure definitions: restart the Lisp/Xephyr test session once. Later
font **configuration** changes use the ordinary configuration reload.
## Cursors
The desktop arrow, clickable bar/menu hand, and cooperating applications share
a configurable Xcursor theme:
```lisp
(:cursor :theme nil :size nil) ; defaults: follow the system
;; Or choose an installed theme and nominal pixel size explicitly:
(:cursor :theme "Adwaita" :size 24)
```
Use just one `:cursor` clause. `:theme` is a theme directory name, not a font
or file path; `:size` is an integer from 8 to 256, or NIL for automatic sizing.
Explicit config wins over `XCURSOR_THEME` / `XCURSOR_SIZE`, which win over the
current X screen's `Xcursor.theme` / `Xcursor.size` Xresources. No environment
variables are required. Without those, the theme is Xcursor's `default`
(including `default/index.theme` inheritance); size uses `Xft.dpi` when
available, otherwise 24px. The theme supplies its nearest available size.
Installed cursor directories, `XCURSOR_PATH`, and theme inheritance are resolved
by libXcursor. A missing asset/theme may fall back to the system default theme.
If libXcursor, ARGB-capable X Render, or usable cursor images are unavailable,
Lime falls back to built-in X cursors. `:theme :core` requests those explicitly.
Inspect the selected settings and each role's source/fallback reason with
`(lime.x11:backend-cursor-report backend)`; the reported theme is the requested
search theme, not proof that an asset came from that directory.
Theme images retain alpha and hotspots. Animated assets use X Render's server
animation support (or their first frame on older servers), not a WM timer.
Explicit `:theme` / `:size` values also become **session-local application
defaults**. Lime publishes cursor-only XSETTINGS for GTK and compatible toolkits,
updates `Xcursor.theme` / `Xcursor.size` in the current X screen's resources,
and passes `XCURSOR_THEME` / `XCURSOR_SIZE` to programs launched through Lime.
You still configure these in Lisp; no environment setup is required. Lime does
not rewrite GTK/Qt files, dconf, the global launch environment, or another X
session. Application-specific cursors (text, resize, drag, etc.) retain their
roles; applications that deliberately override the theme can still do so.
Lime never replaces an existing XSETTINGS manager and yields if one takes over.
The `:session` entry in `backend-cursor-report` reports `:published`,
`:external-manager`, or `:following-system` under `:xsettings`. With an external
manager, configure its cursor theme too if its toolkit settings disagree.
Only explicit values are published: a NIL field follows its ordinary defaults.
Returning both fields to NIL, choosing `:theme :core`, or exiting Lime releases
its manager and restores the Xresources it still owns, preserving intervening
external edits. Some applications cache cursor settings and need reopening;
in particular, GTK 3 can retain a removed setting until restart even though
changes between explicit themes/sizes update live.
After the initial structural upgrade/restart, theme/size changes reload live,
including a hovered widget or an open dropdown. New cursor resources are
prepared first; a later config failure restores the old resources, while a
successful reload releases them. Session publication participates in the same
rollback. No activity or view is recreated.
## Bar appearance
The bar has three regions: `Lime | activities | layout` on the left,
a left-aligned expanding title section, and optional right-aligned status
providers. Only the title section changes colors with Head focus. Its two
one-pixel edges are deliberately quieter than the colored text separators.
Bar colors and padding can be reloaded without restarting or changing a saved View:
```lisp
(:bar :height 22 :padding-x 10
:background "#060a0c" :foreground "#ffdbb0"
:focused-background "#121f25" :focused-foreground "#ffac54"
:unfocused-background "#0b1216" :unfocused-foreground "#a8adb0"
:separator-color "#b88755"
:attention-color "#ffac54"
:attention-marker "!" :attention-indicator-p t
:title-edge-color "#40535d")
```
These are the defaults. Use `:title-edge-color nil` for the edge-free variant.
Use `:logo-color "#768565"` to color the clickable `Lime` label independently
of the Activity and layout labels. NIL (the default) inherits `:foreground`.
Optional `:accent-color` separates menu key hints, hover/selection markers, and
which-key headings from the focused title's foreground. `:menu-label-color`
separates submenu labels from the bar's separators. Both default to NIL to
preserve the old colors (`:focused-foreground` and `:separator-color`,
respectively; headings retain `:foreground`). For cream titles and labels
with yellow hints, add:
```lisp
:focused-foreground "#dbc9a6"
:accent-color "#ffb054"
:menu-label-color "#dbc9a6"
```
These new roles and separately colored status prefixes require one fresh Lime
process when upgrading from the older renderer. Subsequent color-only config
edits can be reloaded normally.
The attention indicator appears as `! N` ahead of the optional status widgets
only while windows request attention. It counts windows across **all** Activities
and appears on every visible Head bar, including when their Activity is parked.
Click it, or use `Super-y → w → a`, to search requesting windows by Activity
and current title. Activity-search details and hidden-window entries also mark
requests. No new global keybinding is installed.
`:attention-color` and `:attention-marker` control that badge; the marker is
1–16 printable characters. `:attention-indicator-p nil` hides the bar badge,
not the protocol handling or menu. The public cached-data queries
`backend-attention-client-ids` (optional `:activity-id`) and
`backend-client-needs-attention-p` are available to custom Lisp UI/providers.
`backend-open-attention-picker` opens the picker, and `backend-attend-client`
performs the same visit without a menu (`:policy :strict` by default).
See [attention behavior](attention.md) for acknowledgement and lease rules.
`:padding-x` is horizontal text padding in pixels (0–128): the outer left and
right margins and the title section's inset
inside its hairlines. The bar vertically centers a stable sample of the font's
letter body, rather than its invisible line spacing. All fields share a
baseline, so changing titles or adding descenders does not make text jump.
Accents and descenders retain their normal positions relative to that baseline;
increase `:height` if the chosen font or fallback glyphs need more room.
Colors must be `#RRGGBB`; invalid settings fail before changing the running
configuration. A failed installation restores the old bar appearance along
with bindings and window styling. Lisp callers can use `MAKE-HEAD-BAR-STYLE`
and the configuration's `BAR-STYLE` slot directly.
Status providers still supply their own text and optional click actions.
The bar draws contrasting ` | ` delimiters between their items; those
delimiters and truncation dots are not clickable. Appearance settings alone
do not enable providers; opt into the widgets above separately. Providers may use text,
Unicode symbols, or icons covered by the configured/fallback fonts.
## Default layout
`:options :default-layout` chooses `:master-stack` (the default), `:monocle`,
or `:manual` for a **new** working arrangement. This includes the initial
Scratch drafts, new Activities, and newly extended portions. Bringing or resuming
an existing draft or recalling a saved View keeps its layout. Reloading this
option changes future drafts only; it never rearranges your current work.
The configuration is read before startup drafts are created. Without a config
file, `start-lime`/`start-backend` also accept `:default-layout`; with a file,
the file's setting wins.
## New-window and close policy
The `:options` clause also keeps client-flow preference out of the layout
engine. `:new-client-focus` controls whether ordinary newly managed windows
take focus. `:new-tiled-client-position` is `:back` (append after the existing
stack) or `:front` (become master-most). `:close-focus-direction` is
`:previous` (toward master in placement order) or `:next` (farther down the
stack); reaching either edge falls back to the closest client on the other
side. These are the fallback defaults for manual and master/stack: `t`,
`:back`, and `:previous`.
Each layout can override only the flow choices it needs:
```lisp
(:layout-flow :monocle
:new-tiled-client-position :front
:close-focus-direction :next)
```
That is Lime's built-in monocle policy already: each new tiled client becomes
the visible one, and closing it advances to the next underlying client. A
`:layout-flow` clause is useful when you want a different monocle cycle, or a
separate policy for `:manual` or `:master-stack`.
## Resizing a window within a column
Master/stack gives each tiled placement a relative weight, initially `1`.
Two windows weighted `2` and `1` take two-thirds and one-third of their column.
The master area and stack normalize separately, leaving their shared divider
unchanged. This also works with several master windows, and along the other
axis when the layout is rotated.
Configure the commands and step size in your own keymap, for example:
```lisp
(:command grow-tile
:label "Grow window in column"
:action (lambda (backend)
(lime.x11:backend-adjust-focused-tile-weight backend 1/4)))
(:command shrink-tile
:label "Shrink window in column"
:action (lambda (backend)
(lime.x11:backend-adjust-focused-tile-weight backend -1/4)))
(:command reset-tile-size
:label "Reset window column share"
:action (lambda (backend)
(lime.x11:backend-set-focused-tile-weight backend 1)))
(:bind #\d grow-tile :modifiers '(:mod-4 :shift))
(:bind #\n shrink-tile :modifiers '(:mod-4 :shift))
(:bind #\o reset-tile-size :modifiers '(:mod-4 :shift))
```
The adjust command defaults to weights between `1/4` and `4`; callers can
override `:minimum-weight` and `:maximum-weight`. Reset affects the focused
window only. A sole window still fills its column.
Weights belong to placements in the current draft and are checkpointed by
normal View save/update operations. They follow windows through tile moves,
float/unfloat, and layout changes. Monocle ignores the weights. Hiding removes
the placement; showing the window again creates a default-weight placement
unless a saved View restores its previous arrangement.
## Borders and gaps
The `:appearance` clause configures window styling independently of layout
policy. Sizes are non-negative integer pixels and colors are `#RRGGBB` strings.
`:inner-gap` is the space **between** tiled outer frames, not a margin on each
window; `:outer-gap` is the space between tiles and the Head's work-area edge
(below the bar). Border widths are included inside the allotted rectangle,
so they cannot make neighboring tiles overlap.
The implementation lives in `src/x11/appearance.lisp`; the render planner
combines it with ICCCM hints, while your configuration owns the actual values.
Only the globally focused client gets `:focused-border`; windows on other
Heads use `:unfocused-border`. Floating windows have borders but no tiling
gaps. Smart gaps remove both gaps and borders whenever a Head displays just
one tiled window, in manual, master/stack, or monocle. Floats above it keep
their borders and do not count as tiles; explicitly hidden clients and tiles
leased to other Heads do not count either. A second visible tile restores the
configured borders and gaps automatically. Monocle always resolves just one
tile, so it remains undecorated even with several clients in its cycle.
These tiles still respect the bar's work area and normal size hints. True
fullscreen bypasses decoration, the bar reservation, and size hints. Size hints may
leave additional unused space, but cannot enlarge a tile beyond its slot.
On very small tiles, excessive gaps/borders shrink to leave usable content.
Changing these settings and reloading configuration repaints existing windows
without changing layout weights, saved Views, leases, or draft dirty state.
Setting a size to `0` disables that component. Without an `:appearance` clause,
the adapter retains its borderless/gapless defaults; default border colors
are white (`#ffffff`) and gray (`#444444`) if only a width is specified.
## Reloading
`lime.config:reload-configuration` rereads the source file selected at start.
It validates commands, bindings, rules, appearance, and bar inputs before changing the
running backend. If a subsequent installation operation fails, Lime restores
the previous binding set, appearance, and bar settings; Activities, Views, Presentations,
leases, and Viewsets are never re-created or changed by a reload. `:startup`
hooks run only at initial `start-lime`, never on reload.
Because a config file is real Lisp, arbitrary top-level side effects are still
the author’s responsibility. Keep top-level work to helper definitions and
`defconfiguration`; place deliberate launches in a `(:startup (lambda
(backend) ...))` clause when that behavior is wanted.
## Rules, deliberately for styling first
`(:rule ...)` supports `:name`, `:class`, and `:title` matchers (a string or a
one-argument predicate), plus a full `:predicate` function of `(backend
client-id icccm-info)`. Its current actions are `:float t/nil` and `:center t`.
Centering requires floating. The first matching rule wins.
Ordinary clients default to tiled, except fixed-size windows (equal positive
minimum and maximum width **and** height in `WM_NORMAL_HINTS`), which start
centered and floating. This handles startup/updater windows without floating
the application's resizable main window. A matching `:float nil` rule can
override that default. These defaults apply only when a client is first
managed; later hint updates do not reclassify existing placements. The usual
`:new-client-focus` setting applies to fixed-size ordinary clients too.
Rules apply only to ordinary new clients. ICCCM transient dialogs keep their
parent’s Head, Activity, and float behavior; dialog-like EWMH clients retain
their floating policy too. Rules intentionally do **not**
mount a View, change an Activity, or transfer a lease: matching an application
class must not silently alter Lime’s task model. Activity-aware launch/routing
can grow later as a separate policy with explicit collision semantics.

221
docs/development.md Normal file
View File

@@ -0,0 +1,221 @@
# Live development
`lime/dev` is the safe route for hacking on a live Lime session. It keeps the
X11 event loop on one thread and starts a Slynk server bound only to
`127.0.0.1`. A connected REPL must send backend work through the developer
mailbox, so a source reload or command cannot race CLX event handling.
It is intended for a nested Xephyr server or a separate X login/session. Do
not start Lime against the same X display already owned by dwm (or another
window manager): X11 permits exactly one root-window substructure redirect
owner.
For the first non-nested test, keep your normal dwm login intact and use a
spare TTY/X server. A minimal `~/.config/lime/xinit-dev` can be:
```sh
#!/bin/sh
exec sbcl --non-interactive --eval '(require :asdf)' \
--eval '(asdf:load-asd "/home/adan/code/common-lisp/lime/lime.asd")' \
--eval '(asdf:load-system "lime/dev")' \
--eval '(lime.dev:run-development-session
:display-name (uiop:getenv "DISPLAY")
:config-file "/home/adan/.config/lime/config.lisp")'
```
Make that file executable, then start it from another TTY with
`startx ~/.config/lime/xinit-dev -- :1 vt8` (adjust the display/VT if either is
already in use). Connect SLY from your ordinary session, and keep that
ordinary session or a second TTY as your escape hatch. Launch test clients on
the new display with `DISPLAY=:1 your-client`, or put deliberate launch forms
in your configuration's existing `:startup` hook.
## Start a session
The configured `:default-layout` starts fresh Head-local drafts in master/stack
unless overridden. Existing drafts and saved Views keep their layouts on
configuration reload. The isolated regression test for startup, multi-head
first visits, adoption, and reload is:
```sh
sbcl --non-interactive --load test/x11-default-layout-smoke.lisp
```
It requires Xvfb and creates its own test display.
Floating drag regression (also on its own isolated Xvfb):
```sh
sbcl --non-interactive --load test/x11-floating-smoke.lisp
```
This checks real Super-drag input, burst coalescing and release ordering,
checkpoint isolation, size hints/borders, and invalidated gestures. It also
compares full-desktop versus geometry-only update timings. Timing is diagnostic;
the regression asserts bounded rendering work rather than a hardware-dependent
frame rate. Restart once after the drag-state change; ordinary config reload
alone does not install the new code/structure.
From the Lime checkout, start SBCL without `--non-interactive` so its process
remains available:
```sh
sbcl --eval '(require :asdf)' \
--eval '(asdf:load-asd "/home/adan/code/common-lisp/lime/lime.asd")' \
--eval '(asdf:load-system "lime/dev")' \
--eval '(lime.dev:start-development-session
:display-name ":1"
:config-file "/home/adan/.config/lime/config.lisp")'
```
The default Slynk endpoint is `127.0.0.1:4005`; connect Emacs/SLY to it.
For a nested test display, replace `:1` with your Xephyr display. The normal
Lime configuration is loaded before the event thread starts.
Every live backend operation from that REPL should use the serialized boundary:
```lisp
(lime.dev:with-development-backend (backend)
(lime.x11:backend-status backend))
(lime.dev:reload-development-configuration)
```
The second form preserves Activities, Views, Presentations, leases, windows,
and the current physical-Head mapping. It only reloads user policy such as
bindings, commands, rules, and bar providers.
You can expose the same actions in your development configuration, which makes
the palette/key layer useful even before connecting a REPL:
```lisp
(:command reload-lime-source
:label "Reload Lime source"
:description "Compile and install changed function-level Lime code"
:category "Development"
:action (lambda (backend)
(declare (ignore backend))
(lime.dev:reload-development-code)))
(:command reload-lime-config
:label "Reload Lime config"
:description "Reload the active configuration without replacing the desk"
:category "Development"
:action (lambda (backend)
(declare (ignore backend))
(lime.dev:reload-development-configuration))
```
## Reload code without resetting your desk
After a function-level source edit, evaluate:
```lisp
(lime.dev:reload-development-code)
```
This compiles and loads changed files in `lime/x11`, reconciles the current
render plan, and returns a `development-reload-report`. A compile failure is
reported in that object and the existing event loop/world stay alive.
Safe reload territory includes ordinary function changes: commands, layout
math, renderer code, palette behavior, bar drawing, and configuration helpers.
It deliberately does **not** promise structural live migration. Restart after
changing `defstruct` slots, package or ASDF topology, backend-thread ownership,
or `lime/dev` itself:
```lisp
(lime.dev:restart-development-session)
```
A backend restart now saves/restores the desk through the display-specific
session file, enabled by default for `lime/dev`. Surviving windows reconnect
with verified identities, including hidden ones. With `:session-file nil`, it
still starts a fresh world. See [session persistence](persistence.md) for the
logout boundary, custom paths, safe writes, and error recovery.
The supervisor and existing Slynk listener stay alive through a slow restart.
A failed pre-restart save retains the old X connection; repair and retry, or
resume the retained backend. If replacement backend startup fails after X has
closed, the control session stays paused: repair and call restart again, not
resume. Restart from an event-loop error uses the last good disk snapshot;
resume instead validates the retained in-memory world.
Inspect the live runtime with:
```lisp
(lime.dev:development-status)
```
When you are finished, use `(lime.dev:stop-development-session)` from the
control REPL. It stops the event thread before closing CLX, remaps managed
clients, and closes the Slynk listener.
## Recovering from an event-loop error
Use the supervised `lime/dev` launcher above for this behavior. The bare
`lime.x11:run-event-loop` API does not supply a recovery thread or REPL.
Expected X11 client races (a window vanishing during adoption/property reads,
or becoming unviewable just before focus) are handled at the adapter boundary.
They increment `:expected-x-errors` in backend diagnostics; they do not pause
the desktop. Ownership conflicts and unrelated protocol/programming errors
are **not** silently ignored.
An unexpected event-loop error pauses event dispatch, releases active keyboard
and pointer grabs, closes the command overlay, and preserves the X connection
and in-memory world. The original event is consumed, not automatically retried;
the remaining queue is retained. Slynk and the serialized mailbox stay alive:
```lisp
(lime.dev:development-status) ; :paused-p, :last-error-report, etc.
;; Repair the problem; function-level changes can still be loaded while paused.
(lime.dev:reload-development-code)
(lime.dev:resume-development-session) ; explicit validation + reconciliation
```
Resume validates the core and adapter registrations, retires disappeared
clients, completes an interrupted MapRequest adoption if needed, and renders
the retained world. It does not replace Activities, saved Views, or drafts.
An invalid model, broken renderer, or closed connection leaves the session
paused and returns an error to the REPL. This is recovery, not transaction
rollback: inspect state after a partially completed command before continuing.
While paused, **WM key bindings, menus, and new window management do not run**;
use SLY for repair/resume or `stop-development-session` for an intentional exit.
Existing application windows/processes are not deliberately destroyed. No
automatic resume loop is used, because replaying arbitrary partial mutations
can make a bug worse.
Diagnostics retain the newest 32 reports in memory and overwrite one private
(mode `0600`) last-fault file under `$XDG_STATE_HOME/lime/`, normally
`~/.local/state/lime/last-error-<display>.log`. Display punctuation becomes `-`;
for example `:1` becomes `last-error--1.log`. `development-status` returns the
exact path and any log-write error. Reports include time, event/window id,
condition, and an SBCL backtrace captured before unwinding. They omit backtrace
arguments, but error messages can still contain private paths/data; review a
report before sharing it. Pass `:diagnostic-file #p"/chosen/path.log"` to the
development launcher to override it, or `:diagnostic-file nil` for memory only.
A logging failure does not end the control session.
This protects ordinary signaled event-loop errors, **not** native Lisp crashes,
process kills, deadlocks, or X-server failure. Disk session persistence is a
[separate layer](persistence.md), not a guarantee that application processes survive.
Initial startup failure before the developer session exists still reports a
normal launch error. Keep a TTY escape hatch during daily-driver testing.
The resilience change adds backend/runtime slots: install it by stopping and
relaunching Lime in a fresh Lisp process once, not by reloading configuration.
Isolated fault-injection regression (requires Xvfb and the `lime/dev` dependencies):
```sh
sbcl --non-interactive --load test/x11-resilience-smoke.lisp
```
It creates its own display and private Slynk port, tests stale X requests,
mid-adoption death/failure, grab release, invalid-state rejection, diagnostic
write failure, slow failed restart/retry, and cleanup failure. It never connects
to the user's display or REPL. Test diagnostics remain in the printed temporary
directory for inspection.

308
docs/model.md Normal file
View File

@@ -0,0 +1,308 @@
# Lime model
This document is the source of truth for Lime's pure Lisp engine. The small
CLX adapter translates this model to X11; it does not redefine it.
## Terms
### Activity
An Activity is a durable, named task container. It owns a pool of normal
clients and a library of saved Views. It is not a virtual desktop and it does
not intrinsically belong to one monitor or all monitors.
Examples: `lime development`, `day job`, `game`.
Every client has exactly one **home Activity**. A shared client additionally
has `client-shared-p` set: any Activity may request it, but it still receives
at most one global Lease. Sharing means availability, not sticky visibility,
cloning, or an automatic placement in every draft. `activity-client-ids` is
the home pool; `activity-available-client-ids` includes shared guests as well.
`set-client-shared` changes this eligibility without granting leases. Making
a client private again removes its requests from foreign saved Views and
live/cached drafts, releasing any foreign lease. Home checkpoints survive;
the client is not automatically summoned home.
`move-client-to-activity` changes home ownership and releases the current
lease. For private clients it prunes obsolete saved/cached/live references
outside the destination, including layout leaves and focus preferences. For
shared clients, valid saved requests survive; only the lease-holder's live
placement is removed. With `:target-head-id`, the placement is queued in the
destination's inactive working draft for that Head (preserving float geometry
and existing destination layout). Without it, the client is simply parked.
It never switches Heads or silently grants a lease in another active draft.
All removal/focus policy is prepared on copies before committing membership.
`activity-deletion-summary` describes deletion without mutating the model.
`delete-activity` accepts only an Activity with no home clients and no mounted
Presentations on any Head. Hidden clients and shared clients borrowed elsewhere
still count as home clients: move or close them first. Deletion never closes
applications, switches Heads, or rehomes clients implicitly.
Deletion removes the Activity's Views and cached/suspended drafts, prunes their
entries from Viewsets, and removes Viewsets left empty. Cached references to
shared guests disappear, but the actual guests retain their homes and leases
elsewhere. An optional `:expected-revision` protects a prior confirmation from
concurrent edits; all validation precedes mutation. There is no built-in undo.
### View
A View is a named, saved, **single-Head** checkpoint within an Activity. It
contains the desired visible client placements, Head-relative layout data,
and preferred focus. It is a definition, not a runtime owner of windows.
Examples within `lime development`: `code`, `debug`, `docs`.
An absent client means “this View does not request it on this Head”; it does
not mean “hide it everywhere in the Activity.”
The concrete tiling representation is a binary tree of `layout-leaf` and
`layout-split` nodes. A split is horizontal or vertical and stores a relative
ratio. The tree contains exactly the View's `:tile` placements; `:float`
placements carry Head-relative geometry separately.
A View or Presentation also records one layout policy:
- `:manual` renders its explicit tree. Focused-split orientation/ratio edits
and slot swaps switch the live draft to this policy.
- `:master-stack` derives a conventional master area and equal stack from tile
placement order. Its options are `:master-count`, `:master-ratio`, and
master/stack `:orientation`; new tiled clients reflow under the same policy.
- `:monocle` retains the full tree and all leases, but renders only the focused
tiled client at the Head's full usable rectangle. Changing focus changes the
visible client without changing leases or the checkpoint.
The policy and its options are snapshot data: they are copied into a
Presentation at activation and only reach a View through explicit save/update.
Floating is deliberately orthogonal to these tiling policies. A float stores a
complete `:x`, `:y`, `:width`, and `:height` rectangle as normalized fractions
of its owning Head; it therefore adapts to Head pixel dimensions without
putting X11 geometry in the model. `toggle-client-floating`,
`toggle-focused-client-floating`, and `set-client-floating-geometry` modify
only an active Presentation. Toggling a client back to tile returns it to the
current tiling policy; master/stack reflows, while the manual tree inserts a
new leaf. As with any live change, the rectangle reaches a View only through
an explicit save/update.
### Head
A Head represents one independently addressable display rectangle, normally a
physical monitor. A Head has zero or one active Presentation. Its pixel
rectangle is backend data: at X11 startup, RandR discovery normally derives
the non-overlapping rectangles from active CRTCs; explicit rectangles remain
available for nested servers and unusual arrangements. RandR change events
refresh the physical-topology snapshot. A caller then explicitly reconciles
the model's logical Head ids: a removed Head's live Presentation can move
intact to one chosen surviving/new Head, or suspend as an unbound cached draft.
No output event silently changes an Activity, View, or Lease.
### Presentation
A Presentation is one Activity's **current working arrangement on one Head**.
Together these arrangements form the Activity's live workspace across Heads.
There is at most
one working arrangement per `(Activity, Head)`, whether visible or inactive.
Recalling a View replaces that arrangement in place, not an extra cached
workspace. `Draft` means no source checkpoint, not a second navigation target.
Switching away keeps the arrangement and releases its visible leases.
Returning restores current locations, unsaved windows, layout and focus.
Explicit moves/summons remove previous live locations, including inactive
ones; a later return cannot reclaim those windows. Hiding survives returns.
The low-level Head-local `open-activity-draft` operation creates an empty first
portion; it never gathers windows from another Head. Ordinary frontend
navigation adds a portable/extended policy above this operation.
A **portable** Activity selects one working arrangement independent of its
physical Head. `bring-presentation-to-head` moves/resumes that arrangement;
if it is displayed elsewhere, it swaps with the target Head's current
arrangement. Active leases remain attached to their Presentation objects.
Hidden clients stay hidden, dirty layouts stay dirty, and checkpoints are not
recalled. The displaced Activity's cached portions transpose between the same
two Heads, preserving its full working state rather than overwriting a cache.
An inactive incoming arrangement changes only the destination's active selection.
Explicitly extending an Activity enables independent Head-local portions.
This is navigation intent, not a second Activity type or a window-count rule;
closing/hiding the final window on a portion never automatically folds it.
The adapter persists this policy separately from the core world and can make
an Activity portable again after its other portions are empty and inactive.
Only explicit checkpoint recall requests historical placements and offers
strict/partial/take policies. A partial recall may expose missing slots for an
explicit reclaim command, but Activity return drops those requests instead of
retrying them. Saved checkpoints never change through a move or partial recall.
Recalling a checkpoint on another Head detaches its older inactive source
label while preserving the old arrangement's remaining windows and layout.
When topology reconciliation suspends a draft from a removed Head, its
`head-id` is temporarily absent and it holds no leases; an explicit resume can
bind that draft to any current Head.
### Lease
A Lease is the exclusive, runtime-only right for one active Presentation to
show one real client. It is not persisted in a View.
```text
client -> zero or one active Presentation lease
```
No lease means the client is parked. One lease means it is visible in that
Presentation. Two leases are invalid: a real X11 window is not a clone.
### Viewset
A Viewset is a deliberate whole-desk shortcut, not the default switching
behavior. It is a saved mapping of Heads to Activity/View selections:
```text
Development
left -> Lime development / Code
right -> Lime development / Debug
After work
left -> Game / Play
right -> Comms / Chat
```
Its activation uses its own preflight/plan/commit machinery, but validates all
target Heads together. A Viewset saves durable `Head -> View` selections—it
does not serialize mutable Presentations, drafts, focus overrides, or Leases.
Capturing the current desk therefore requires every selected Head to be mounted
from a saved View; save a View first when a Head holds an unsaved draft.
All target Heads release their outgoing leases before any incoming View is
mounted. That makes a genuine swap safe:
```text
before: left -> Code, right -> Play
after: left -> Play, right -> Code
```
Neither incoming View conflicts with the other’s outgoing lease or mounted
View. A client leased on an unselected Head remains an ordinary external
collision, resolved explicitly as strict, partial, or take. If two target Views
both request the same client, the Viewset is rejected as internally ambiguous;
Lime never chooses a winner based on entry order.
## Invariants
1. A client has exactly one home Activity; shared clients are eligible elsewhere.
2. Each Head has at most one active Presentation.
3. A named View has at most one active mounted Presentation.
4. A normal client has at most one Lease globally.
5. A Lease must point to an active Presentation in an eligible Activity
(home, or any Activity for a shared client), containing a placement for it.
6. A saved View never changes as a side effect of a lease collision.
7. Switching one Head changes only that Head unless an explicit operation
transfers a client lease, revokes sharing, or activates a named Viewset.
8. Each `(Activity, Head)` has at most one current working arrangement.
9. Each client has at most one current live location, even while undisplayed.
Missing slots from an explicit partial recall are not live locations.
10. A source checkpoint labels only one working arrangement. Saved View
definitions may still contain overlapping sets of windows.
## Activation is a plan, not a UI monolith
The core deliberately separates collision detection, resolution choice, and
state mutation:
```text
preflight-view-activation
-> Activation Report: ready / conflicts
-> strict, partial, or take plan builder
-> commit-activation-plan
```
The menu is only one consumer of the report:
```text
Docs needs Emacs, currently leased by Left / Code
Open reduced -> build-partial-activation-plan
Move Emacs here -> build-taking-activation-plan
Go to Left -> frontend focus command
Cancel -> no plan
```
This lets the same semantics work from keyboard bindings, an on-screen menu,
IPC, or a Lisp REPL.
### Viewset activation is a grouped plan
```text
preflight-viewset-activation
-> Viewset Report: all selected Heads, outgoing group, conflicts
-> strict, partial, or take Viewset plan builder
-> commit-viewset-plan
```
The grouped preflight deliberately excludes leases and mounted Views held by
selected Heads’ outgoing Presentations, because the single commit releases all
of them first. It still checks stale world revision and every target Head’s
outgoing Presentation id immediately before mutation.
## Topology reconciliation is a plan too
RandR supplies the desired physical Head identifiers, but it never gets to
choose task routing. The separate core sequence is:
```text
preflight-head-topology-reconciliation
-> Head Topology Report: retained / added / removed / live removed sources
-> build-head-topology-reconciliation-plan with explicit source -> destination moves
-> commit-head-topology-reconciliation-plan
```
An incoming move keeps the exact live Presentation, including its unsaved
layout, focus, and leases. If a surviving destination already has a live
Presentation, that destination becomes a normal cached draft and releases its
leases before the incoming Presentation mounts. A removed source omitted from
the move list becomes an unbound, lease-free cached draft instead. Added Heads
with no incoming move are genuinely blank; a normal Activity or View command
can mount something there later.
## Collision behavior
The normal strict command makes no change if a requested client is leased by a
different active Head. It does not silently use “last request wins.”
Two explicit alternatives exist:
- **Partial activation** mounts the View with free clients and records the
contested clients as unavailable slots.
- **Take activation** transfers the contested leases atomically to the target
Presentation. The source Presentation becomes temporarily incomplete; its
saved View remains unchanged.
A newly freed client is never auto-awarded to another Presentation. An
explicit reclaim command fills an available slot, avoiding windows that jump
between monitors unexpectedly.
## New windows and snapshots
New clients join the focused Head's active Activity and live Presentation.
They do not retroactively appear in old saved Views.
`show-client-on-presentation` changes only the live draft;
`update-view-from-presentation` is
the deliberate checkpoint update operation.
Updating a partially mounted/degraded Presentation is rejected by default so a
missing contested client cannot be silently erased from a reliable checkpoint.
## Backend boundary
The model knows nothing about X11, CLX, pixels, or an event loop. The backend
translates a committed logical transition into batched effects:
```text
logical plan -> unmap / configure / map / stack / focus -> X11
```
The backend must preserve the model's one-lease invariant and use X11 event
feedback only to update client lifecycle state. A permanently destroyed
client is retired from its Activity, saved Views, live/cached Presentations,
and lease; it cannot leave a phantom tiling slot behind. Layout trees, floating
geometry, Head work areas, RandR discovery and hotplug-migration policy, ICCCM/EWMH, and input binding
policy all belong above or below this core rather than inside lease resolution.

149
docs/persistence.md Normal file
View File

@@ -0,0 +1,149 @@
# Session persistence
Lime saves the **desk**, separately from your Lisp configuration: Activities,
named Views and Viewsets, current and cached drafts, hidden/shown membership,
home Activity and shared eligibility, layout order/options/weights, floating rectangles, focus, dirty flags, partial
View state, and logical Head selections. It does not serialize CLX objects,
fonts, key bindings, callbacks, running processes, or application contents.
`lime/dev` enables this automatically. The normal location is
`$XDG_STATE_HOME/lime/sessions/`, falling back to `~/.local/state/lime/sessions/`.
Files are separate for each DISPLAY (for example `display-_3A0.sexp` for `:0`,
`display-_3A99.sexp` for `:99`). Your nested test desk does not overwrite your
normal desktop's desk. A lifetime writer lock prevents two backends from using
one explicit file simultaneously.
Select a different file or disable persistence with launcher keywords—not an
additional Lime environment variable:
```lisp
(lime.dev:run-development-session
:config-file #p"/home/adan/.config/lime/config.lisp"
:session-file #p"/home/adan/.local/state/lime/my-desk.sexp")
;; For a disposable test:
(lime.dev:start-development-session :display-name ":99" :session-file nil)
```
The lower-level `lime.x11:start-lime` / `start-backend` APIs default to no
persistence; pass `:session-file :default` or a pathname to enable it. An
explicit `:world` and a session file are mutually exclusive startup sources.
Configuration reload does not change the selected session file.
Configured startup hooks still run normally; hooks that create named Activities
should tolerate those Activities already existing in a restored desk.
`scripts/run-xephyr-dev` supports `--session-file PATH` and `--no-session`.
Without either option it uses the display-specific default.
## What a restart restores
On a **Lime-only restart within the same X server**, surviving clients reconnect
to their Activities, checkpoints, and drafts—including unmapped hidden clients.
The adapter verifies a random server token plus each client's XID and random
window token. An XID, title, WM_CLASS, or PID alone is never enough: reused ids
must not attach an unrelated application's window to an old checkpoint.
On **logout, reboot, or X-server replacement**, the names and non-client layout
settings survive, but the old windows do not. Missing clients are retired from
the saved arrangements; an empty View remains a named checkpoint. This first
version does **not** relaunch applications, restore terminal/browser contents,
reserve placeholder slots for dead windows, or guess how newly launched
windows should fill an old arrangement. Application/session-relaunch rules are
a separate feature.
Current physical Heads remain authoritative. Matching logical Head ids resume
their saved selections. Drafts from removed Heads are suspended, not moved to
an arbitrary other monitor or deleted; new Heads get ordinary blank drafts.
Viewsets retain their logical mappings even when an output is absent. Leases
are reconstructed and validated, never accepted as serialized ownership objects.
## Saving and failures
Autosave observes model/focus changes on the owner thread. After 750 ms of
quiet, or five seconds of ongoing edits, it captures a validated plain-data
snapshot. Active pointer drags postpone capture until release. A single worker
writes snapshots off the X event thread, keeping only the latest pending one.
An unchanged desk produces no disk writes. A crash can lose edits since the
last completed autosave; this is a snapshot system, not a per-operation journal.
Normal shutdown flushes the final valid desk. A normal developer restart saves
**before closing X**; if that save fails, restart aborts with the old backend
still available for repair. Restart after an event-loop fault instead uses the
last good disk snapshot, avoiding an automatic save of partially mutated state.
`resume-development-session` still uses the retained in-memory world, not disk.
For an explicit flush or inspection from SLY:
```lisp
(lime.dev:save-development-session)
(lime.dev:with-development-backend (backend)
(lime.x11:backend-session-status backend))
```
The status includes the file, restore/missing-client report, last-save time,
submitted/completed write generations, and restore/snapshot/write errors. Disk
write failures are nonfatal to ordinary operation and retry with five-second
backoff; explicit flush reports the error to its caller. Invalid in-memory
snapshots block automatic saving until repaired and explicitly saved again.
Files are mode `0600`. Writes use a private temporary file in the same
directory, file `fsync`, atomic rename, and directory `fsync` on Linux. A failed
write before rename leaves the old file untouched; readers cannot see a partial
replacement. The format is versioned, bounded plain data. Reading never calls
`load`/`eval`, disables read-time evaluation, and rejects reader dispatch,
circular structures, excessive nesting, and trailing data. Unknown versions,
invalid relationships, duplicate leases, or malformed files are rejected before
replacing the startup world.
A rejected file is **not overwritten**: Lime starts a fresh usable desk with
autosave blocked and reports the error. Inspect/back up the file first. To
deliberately replace it with the current desk:
```lisp
(lime.dev:save-development-session :replace-invalid-p t)
```
This does not override another session's file lock. Stop that other session or
choose a different path. Back up important files separately; atomic writes are
not a backup/history system. Names and window associations can be private even
though the file contains no application buffers or process contents.
## Installing and testing
Membership support adds a client structure slot and ASDF components. Relaunch
Lime in a fresh Lisp process; configuration reload alone cannot install it.
World snapshots use schema version 3. The X11 session envelope uses
version 3, with ordered Activity positions, stable local-View shortcut slots,
last-arrangement history, and portable/extended Activity navigation policy.
Version-1/2 envelopes remain readable. Old Activities with one substantive
arrangement become portable; genuinely split arrangements remain extended.
This inference happens before missing-client pruning, preserving multi-monitor
intent across reboot. Old empty drafts are retained, not recalled or deleted.
Version-1 envelopes also receive initial shortcut assignments. Migration does
not move a window or alter what the Heads display. Navigation metadata is
validated before a saved world is installed. An explicit extended choice stays
extended when all its windows close; it is never re-inferred from window count.
Version 1 worlds load with all clients private. Version 1/2 worlds consolidate
historical drafts into one current arrangement per Activity/Head. Active state
wins, then saved navigation history, then the newest generated Presentation
id. Actual leases win duplicated window locations; missing checkpoint slots
are not live locations. Windows and saved Views are retained, and old source
labels are detached where a checkpoint was recalled elsewhere. Migration is
performed on a candidate world before installation; version 3 rejects duplicate
live locations rather than silently repairing them. Keep a backup before
upgrading or downgrading; older Lime builds may not read these schema versions.
The same-X-server identity rules above still apply to moved/shared clients.
```lisp
(asdf:test-system "lime/persistence")
```
```sh
sbcl --non-interactive --load test/x11-persistence-smoke.lisp
sbcl --non-interactive --load test/x11-membership-smoke.lisp
```
The smoke test uses its own Xvfb, temporary state files, and fake client windows.
It tests graceful/abrupt restart, hidden clients, dirty checkpoints, partial
leases, changed topology/identity, corrupt files, disk failures, and the actual
developer restart path. It does not connect to your running desktop.

686
docs/xephyr-demo.md Normal file
View File

@@ -0,0 +1,686 @@
# Xephyr demo
`lime/x11` is Lime's first deliberately small adapter from the pure
Activity/View/Presentation/Lease model to real X11 operations. It exists to
make the model visible and testable early. It is not a complete X11 window
manager yet.
## Run it safely
Use a clean nested X server. Do not run the prototype on the same root window
as an existing window manager: X11 allows only one client to select
`SubstructureRedirect` on a root window.
```sh
Xephyr :99 -ac -screen 1000x700 -br -nolisten tcp
```
In a second terminal, in the repository root, start Lime in a Lisp image with
Quicklisp, CLX, Babel, and CFFI available. Install the native Pango/Cairo
libraries (including PangoCairo) and your desired fonts first:
```lisp
(ql:quickload '(:clx :babel :cffi))
(asdf:load-system "lime/x11")
(lime.x11:run-demo :display-name ":99")
```
For a user-owned configuration file instead of the starter map, load the same
system and start through `lime.config:start-lime`:
```lisp
(lime.config:start-lime
:display-name ":99"
:config-file #P"/home/adan/.config/lime/config.lisp")
```
The configuration file interface, command registry, reload behavior, and
initial floating rules are documented in [Configuration](configuration.md).
In a third terminal, create some clients:
```sh
DISPLAY=:99 xterm
DISPLAY=:99 xterm
```
For two clients the fallback layout divides the 1000px-wide server into two
500px columns below Lime's default 22px Head bar. When either client exits,
the other immediately occupies the full usable width. This was exercised
against Xephyr during implementation.
## Read the first Head bar
Every Head owns a small persistent top bar. Its initial left-to-right shape is
roughly:
```text
Lime | chill work code | []= focused window title music | volume | clock
```
The local Activity is highlighted. Saved Views are checkpoints recalled from
the menu, not nested workspaces, so there is no View/Draft/dirty suffix. `[]=` is
master/stack, `|M|` is the retained manual tree, and `[N]` is monocle with `N`
tiled clients in its stack, including the visible one. Hidden Activity clients
and clients leased elsewhere are excluded. Individual floats do
not change that token; Lime has no fake all-floating layout. The expanding,
left-aligned title section has quiet one-pixel edges on both sides. Its
background and text colors identify the focused Head, without a center
underline or a redundant "left Head" or "right Head" label.
The fields use actual font measurements rather than fixed-width columns.
The left fields use measured, contrasting ` | ` separators; long names,
titles, and status text are shortened to fit their pixel budgets. Click
targets follow the displayed text, excluding status separators and ellipses.
The right-hand music/volume/clock above are illustrative providers, not built-in
integrations. Bar colors, including optional title edges, live in `(:bar ...)`.
Titles prefer UTF-8 `_NET_WM_NAME`, then Latin-1 `WM_NAME`, then the client
class/name or "Untitled window". Property notifications refresh the bar
immediately. The shared Pango/Cairo renderer preserves Unicode, shapes text,
and uses system font fallback, including color emoji when installed. Configure
both the bar and dropdown with `(:font :family "monospace" :size 14 :weight
:normal)`; size is in pixels. Long text is shortened at grapheme boundaries,
not in the middle of an accented character or joined emoji. See
[font configuration](configuration.md#fonts) for family lists and live reload.
Normal tiles and floats use the client work area below the bar. A real EWMH
fullscreen client takes the whole physical Head and hides its bar. Click
`Lime` to open the same Activity/View palette as `Super-y`; click the layout token to cycle
master/stack and monocle. Manual remains available through Lisp and saved Views,
but is hidden from the everyday cycle and layout menu. The palette is a dropdown below the bar, so
the current Activity stays visible while searching.
The right side has no built-in shell commands. Configure optional status
providers in Lisp instead:
```lisp
(lime.x11:backend-set-head-bar-status-providers
*lime*
(list (lambda (backend head-id)
(declare (ignore backend head-id))
(lime.x11:make-head-bar-segment :id :clock :text "12:34"))))
```
Each provider receives `(backend head-id)` and may return one segment or a
list. A segment can also carry a `:click-action` function of those same two
arguments.
## Exercise Activities and Views from the REPL
The first control surface is intentionally Lisp-first. Start the backend in
the REPL and give its event loop a background SBCL thread:
```lisp
(defparameter *lime* (lime.x11:start-backend :display-name ":99"))
(defparameter *lime-event-thread*
(sb-thread:make-thread (lambda () (lime.x11:run-event-loop *lime*))))
```
Now the initial task workflow is directly usable:
```lisp
;; 1. A fresh named task with a blank unsaved live draft.
(lime.x11:backend-start-activity *lime* "wm" "WM project")
;; 2. Launch clients with DISPLAY=:99; they enter the WM project draft.
;; 3. Capture the current arrangement as a checkpoint.
(lime.x11:backend-save-view *lime* "wm-code" "Code")
;; 4. Arrange through the model, then capture a second checkpoint.
(lime.x11:backend-set-layout-root *lime* my-debug-layout)
(lime.x11:backend-save-view *lime* "wm-debug" "Debug")
;; 5. Recall either View. The selected policy is explicit.
(lime.x11:backend-activate-view *lime* "wm-code" :policy :strict)
(lime.x11:backend-activate-view *lime* "wm-debug" :policy :partial)
;; 6. Inspect the active Head without digging through structures.
(lime.x11:backend-status *lime*)
```
`backend-update-view` deliberately overwrites an existing checkpoint from the
live draft. `backend-resume-presentation` restores a cached draft by id; both
accept the same `:strict`, `:partial`, and `:take` collision policies where
they apply. Every transition applies the resulting Presentation to X11 before
returning.
If a REPL or future IPC client only has a human-facing name, these helpers make
the same palette workflow available without hand-writing an id:
```lisp
(lime.x11:backend-start-named-activity *lime* "WM project")
(lime.x11:backend-save-named-view *lime* "Code")
```
They derive readable unique ids and make the saved View current, so the normal
`backend-update-current-view` command follows the new checkpoint.
## Capture and recall a whole-desk Viewset
A Viewset is the explicit multi-Head companion to a single-Head View. It saves
the current `Head -> saved View` mapping, then later recalls every named target
as one atomic transition:
```lisp
;; Every selected Head must currently be mounted from a saved View. By default
;; this captures all configured Heads in display order.
(lime.x11:backend-save-named-viewset *lime* "Work desk")
;; Later, recall every mapped Head together.
(lime.x11:backend-activate-viewset *lime* "work-desk" :policy :strict)
```
`backend-save-viewset` accepts an explicit id/name and optional `:head-ids`
when you deliberately want a subset. It never serializes the live draft; save
the relevant View first if a Head has unsaved changes. Viewset recall uses the
same `:strict`, `:partial`, and `:take` vocabulary as View recall, but checks
the selected Heads as one outgoing group. That is what makes a saved left/right
swap work without briefly duplicating a View or lease. A client held by an
unselected Head can be left there (`:partial`) or deliberately taken (`:take`).
Two target Views that request the same client are rejected as an ambiguous
Viewset—Lime will not pick one based on ordering.
`bind-viewset-key` is the corresponding opt-in Lisp configuration helper:
```lisp
(lime.x11:bind-viewset-key *lime* #\w "work-desk")
```
The focused client is also saved on the live Presentation. Focus a particular
visible client or cycle its leased clients without exposing raw CLX calls:
```lisp
(lime.x11:backend-focus-client *lime* "x11-...")
(lime.x11:backend-focus-next-client *lime*)
(lime.x11:backend-focus-previous-client *lime*)
```
`backend-status` includes `:focus-client-id`. The renderer sends X input focus
to that registered visible client after it maps all windows; a blank selected
Head focuses the root window instead.
## Use built-in layouts and manual tree edits
Layout policy belongs to the active Head-local Presentation, so it is an
unsaved draft change until `backend-save-view` or `backend-update-view` is
called. The first built-ins are master/stack and monocle:
```lisp
;; First tiled client is the 60% left-hand master; the rest share the stack.
(lime.x11:backend-use-master-stack-layout
*lime* :master-count 1 :master-ratio 3/5 :orientation :horizontal)
;; Promote the selected Head's focused tiled client to the master slot.
(lime.x11:backend-promote-focused-client *lime*)
;; Full-Head focus mode. Other tiled clients remain leased but are unmapped.
(lime.x11:backend-use-monocle-layout *lime*)
(lime.x11:backend-focus-next-client *lime*) ; shows the next client in monocle
;; Return to the retained binary tree, then edit the focused client's nearest
;; split. These actions deliberately make the draft :MANUAL.
(lime.x11:backend-use-manual-layout *lime*)
(lime.x11:backend-orient-focused-split *lime* :vertical)
(lime.x11:backend-resize-focused-split *lime* 1/20)
(lime.x11:backend-swap-focused-client *lime* :direction :next)
```
`backend-status` exposes `:layout-mode` and `:layout-options` for UI/IPC
frontends. The optional starter map below covers everyday commands; the named
helpers add task and physical-output shortcuts without hard-coding them.
## Float a client and use Super-drag
Floating is placement state, not a second kind of client or lease. Toggle the
focused client, then optionally set an exact Head-relative rectangle:
```lisp
(lime.x11:backend-toggle-focused-client-floating *lime*)
;; All values are normalized fractions of the selected Head.
(lime.x11:backend-set-client-floating-geometry
*lime* "x11-..." :x 1/5 :y 1/6 :width 3/5 :height 2/3)
;; The same generic operation is useful to a future menu or IPC client.
(lime.x11:backend-toggle-client-floating *lime* "x11-...")
```
The renderer places visible floats above tiled content. `backend-status`
includes `:floating-client-ids`; save or update the active View to make a
float checkpoint durable.
On a floating client, hold Super (`Mod4`) and use Button1 to move or Button3
to resize. Super+Button2 toggles the clicked client's floating placement, the
same way as Super+Space. The adapter converts each root-pixel drag motion into
the same normalized geometry command above. Super-drag only claims floating
clients; on a tiled client it simply focuses the window and changes no geometry.
## Click to focus
An unmodified Button1 anywhere in a managed client's window tree selects the Head leasing it
and focuses that client, then the press is replayed to the application. Button1 on a blank part of the root selects the Head under the pointer
and focuses root, while retaining that Presentation's saved client-focus target
for later keyboard cycling or another client click. A background click is thus
not a View edit.
The initial pointer policy reserves Super+Button1/2/3 for float move/toggle/
resize. Menus, focus-follows-pointer, button rebinding, and robust Caps/Num-
Lock modifier normalization remain separate future policies.
## Inspect ICCCM client facts or close a client
`x11/icccm.lisp` tracks the conventional title, resource name/class,
`WM_PROTOCOLS`, InputHint, transient parent, and normal size hints for every
managed client. This data stays out of the Activity/View model and is available
to a future UI:
```lisp
(let ((client-id (first (getf (lime.x11:backend-status *lime*) :client-ids))))
(lime.x11:backend-client-icccm-info *lime* client-id))
;; Sends WM_DELETE_WINDOW when supported; only legacy clients use XKillClient.
(lime.x11:backend-close-focused-client *lime*)
```
Focus now honors ICCCM's InputHint and sends `WM_TAKE_FOCUS` when a client
advertises it. When Lime denies a tiled client's ConfigureRequest, it restores
the model geometry, adjusted to its cached normal hints, and sends that client
a synthetic ConfigureNotify response.
When a lease makes a client visible Lime publishes `WM_STATE` `NormalState`;
when a View hides it, Lime publishes `IconicState` while retaining the client
in its Activity. Those renderer-originated unmaps are tracked explicitly.
If the application later unmaps itself, Lime recognizes the distinct ICCCM
withdrawal, removes `WM_STATE`, and retires that client from its Views.
## Fullscreen and small EWMH compatibility
`Super-f` follows your dwm binding and toggles true fullscreen for the focused
client. It also responds to a client's `_NET_WM_STATE_FULLSCREEN` request,
including the initial state a game publishes before mapping. The fullscreen
window receives the entire rectangle of the Head holding its exclusive Lease,
is raised above the other windows on that Head, and becomes the input focus.
Normal size hints intentionally do not constrain that fullscreen rectangle.
Fullscreen is temporary EWMH adapter state, not a saved View edit. Switching
to a different View hides the game through its normal Lease; returning to the
View restores fullscreen on whichever Head legally holds that Lease. A second
fullscreen request on the same visible Head replaces the first. Lime also
advertises and handles `_NET_ACTIVE_WINDOW` for already visible clients and
`_NET_CLOSE_WINDOW`; it publishes `_NET_ACTIVE_WINDOW` while it controls
focus. Lime also creates the required `_NET_SUPPORTING_WM_CHECK` window and
keeps `_NET_CLIENT_LIST` plus `_NET_CLIENT_LIST_STACKING` current, so ordinary
desktop tools can identify the WM and enumerate its clients. It deliberately
does **not** implement fake numbered EWMH desktops.
`_NET_WM_WINDOW_TYPE_DIALOG`, `UTILITY`, and `SPLASH` receive the same
centered floating/focus behavior as a transient dialog when no
`WM_TRANSIENT_FOR` parent exists. `DOCK` and `DESKTOP` windows are mapped at
their application-supplied geometry but never become Activity/View task
clients. External dock struts are intentionally a later work-area pass.
## Dialogs and normal size hints
When a newly managed window declares `WM_TRANSIENT_FOR` for a client Lime is
already managing, it follows that parent’s active Presentation and Head rather
than the most recently selected Head. Lime adds it as a float, centers it over
the parent, and focuses it. The dialog is still a normal placement and lease,
so it disappears/restores with the same View rules as every other client.
Before every X11 configure, Lime applies the client’s `WM_NORMAL_HINTS`
minimum/maximum size, base-size, and resize increments. This includes tiling,
direct float geometry commands, and every Super-resize motion. The requested
Lime rectangle remains model state; the final client-compatible pixel rectangle
is adapter-local, so client constraints never mutate a saved View. If a client
demands more than the whole Head, Head containment wins.
## Exercise two independent Heads
On a normal X11 session, `start-backend` uses RandR automatically. Every active
CRTC becomes a Lime Head named after its representative output (for example,
`DP-1` and `DP-3`). Mirrored outputs sharing one CRTC deliberately become one
Head, so Lime never renders the same task rectangle twice. Inspect the live
hardware snapshot with:
```lisp
(lime.x11:backend-randr-status *lime*)
```
Xephyr normally exposes one virtual output, so explicit rectangles remain a
useful way to exercise two independent Heads in the nested demo. This
1000×700 server becomes a left and right 500×700 Head:
```lisp
(defparameter *lime*
(lime.x11:start-backend
:display-name ":99"
:heads
(list
(lime.x11:make-x11-head :head-id "left"
:x 0 :y 0 :width 500 :height 700)
(lime.x11:make-x11-head :head-id "right"
:x 500 :y 0 :width 500 :height 700))))
```
Each configured rectangle receives its own active Presentation: initially an
unsaved, blank `Scratch` draft rather than a fabricated empty View. A saved
View is never mounted on two Heads, while two Head-local drafts may show the
same Activity when their exclusive window leases do not collide. Set the
temporary routing target before opening a window for a different Head:
```lisp
;; Keep the existing Scratch task on the left.
(lime.x11:backend-start-activity *lime* "game" "Game" :head-id "right")
(lime.x11:backend-select-head *lime* "right")
;; A subsequent DISPLAY=:99 client joins Game on the right.
(lime.x11:backend-status *lime* :head-id "left")
(lime.x11:backend-status *lime* :head-id "right")
```
`backend-select-head` selects the routing target and applies that Head's
saved focus target as real X input focus. Head rectangles must be positive,
inside the X root, and non-overlapping. Explicit `:heads` always takes
precedence over discovery; pass `:head-discovery :none` to force the old
single-root fallback instead.
Lime subscribes to RandR screen/CRTC/output notifications for a discovered
startup. An event only refreshes the observed topology and marks it pending:
it will never silently decide where an Activity, View, or Lease should go when
a monitor is unplugged. `backend-refresh-randr-heads` safely applies a
resolution/position change when the same Head ids remain; an added or removed
Head returns `:reason :topology-changed`. For that case, keep observation,
choice, and mutation separate:
```lisp
;; Read-only data for a future palette, IPC client, or REPL inspection.
(defparameter *randr-change*
(lime.x11:backend-randr-topology-report *lime*))
(getf *randr-change* :removed-active-sources)
;; Example after DP-3 disappears: keep its exact live draft on DP-1.
;; Omit a removed source from :MOVES to suspend its draft and release leases.
(defparameter *randr-plan*
(lime.x11:backend-build-randr-topology-plan
*lime*
:moves
(list (lime.core:make-head-topology-move
:from-head-id "DP-3" :to-head-id "DP-1"))))
;; Rereads RandR, rejects a physically stale plan, then renders the result.
(lime.x11:backend-apply-randr-topology-plan *lime* *randr-plan*)
```
An added Head without an incoming move is blank. Select it, then use the
ordinary palette or `backend-start-named-activity` / `backend-activate-view`
commands to put the task you actually want there.
## Watch a cross-Head lease collision resolve
With two clients on the left Head, save two overlapping checkpoints from that
Head and ask to mount one on the right:
```lisp
(lime.x11:backend-save-view *lime* "code" "Code" :head-id "left")
(lime.x11:backend-save-view *lime* "docs" "Docs" :head-id "left")
;; Read-only: returns an ACTIVATION-REPORT with :CLIENT-LEASED conflicts.
(lime.x11:backend-preflight-view-activation *lime* "docs" :head-id "right")
;; "Open what is available": left stays visible; right records holes.
(lime.x11:backend-activate-view-partially *lime* "docs" :head-id "right")
;; "Move windows here": explicit transfer; the old left draft becomes partial.
(lime.x11:backend-activate-view-taking *lime* "docs" :head-id "right")
```
`backend-activate-view-strictly` is the default “open only if complete”
command: it signals `activation-blocked` rather than changing either Head if
any lease conflicts. The preflight function does no mutation; a future menu
can inspect its report, offer the complete, partial, and move-here commands,
or cancel. The adapter applies all configured Heads after a transition, so
an explicit take unmaps/reconfigures the source and target in one visual
update.
## Install the starter keymap and name your own task shortcuts
`start-backend` (and therefore `run-demo`) installs a Super (`Mod4`) keymap
for ordinary window operations. It has no Activity, View, or physical Head
names, so it does not guess at your workflow. Reinstall it after clearing or
changing bindings, or opt out at startup for a fully custom map:
```lisp
;; Reinstall with a different primary modifier.
(lime.x11:install-default-keybindings *lime* :modifiers '(:mod-1))
;; Or start with no defaults and bind everything yourself.
(lime.x11:start-backend :display-name ":99" :install-default-keybindings-p nil)
```
| Binding | Command |
| --- | --- |
| Super-t / Super-h | Focus next / previous visible client |
| Super-Shift-t / Super-Shift-h | Move focused tiled client forward / backward |
| Super-Return / Super-z | Promote focused client / focus primary tiled client |
| Super-Tab / Super-Shift-Tab | Cycle master-stack ↔ monocle forward / backward |
| Super-d / Super-n | Shrink / grow master area (master-stack only) |
| Super-Up / Super-Down | Add / remove a master client (master-stack only; minimum one) |
| Super-Space | Toggle focused client floating |
| Super-f | Toggle focused client fullscreen |
| Super-i / Super-Shift-i | Show a hidden/shared window / hide the focused window |
| Super-Shift-c | Close focused client |
| Super-Shift-q | Quit Lime cleanly |
| Super-Shift-s | Update the mounted saved View from its live draft |
| Super-p | Open fuzzy PATH-program picker |
| Super-y | Open the Activity/View command palette |
| Super-e / Super-Shift-e | Select next Head / send focused client to next Head |
`Super-Shift-i` parks the focused window only from the current Head-local
draft: it does not close, minimize, or remove the client from its Activity.
`Super-i` opens a fuzzy list of parked Activity windows plus shared windows and reveals the
chosen one in the current draft. If the window is presently visible on a
different Head, Lime asks for an explicit **Move here** confirmation rather
than showing one X11 window twice. Saving or updating a View records its
current shown/parked set, so this is the everyday way to keep a focused
monocle or tiled context uncluttered without inventing another workspace.
Under `Super-y → w` (Windows), `m` moves the focused window to an existing
Activity. Create one first through `a → n` if needed. The move keeps you here
and queues the window in the destination's working draft for this Head; opening
that Activity shows it. A private window is removed from its old checkpoints
and cached drafts, so recalling them cannot pull it back across Activities.
Another Head already displaying the destination Activity is not rearranged.
`Super-y → w → s` makes the focused window shared. It keeps one home Activity,
but becomes available through `Super-i` in every Activity. It does not appear
everywhere automatically. If another Head leases it, the picker offers an
explicit **Move here**; shared slots taken away can be summoned back using
the same picker. Views may save shared placements, subject to normal lease
conflicts on recall. On a shared window, `w → s` instead offers **Make private to
Activity**: the Activity where you're using it becomes its home. The window stays
on this Head, with the same position and focus. Saved views in this Activity keep
it; references in every other Activity (including its old home) are removed.
The confirmation names the Activity and defaults to Cancel; choose the named
action with arrows and Enter, or click it. Changing the desk while the prompt is
open invalidates the choice rather than acting on a different window or Activity.
`Super-Shift-e` (or `Super-y → h → s`) sends the focused window to the next
Head. Same-Activity and shared-window transfers happen immediately, without
changing home membership. For a private window crossing Activities, the
dropdown names both Activities and offers **Cancel** (default) or **Move to
destination**. Choose with arrows and Enter, or click; Escape cancels. The
warning explains that moving removes the private window from its old saved
views. Changing the desk while this confirmation is open invalidates it.
Neither Head switches Activities; keyboard focus follows the window automatically,
so you don't need a second `Super-e`. `Super-e` still switches monitors without
moving a window. The destination selects the arriving
window in its arrangement, so it is visible even in monocle. A destination View or
draft that already contains the client controls the placement there; otherwise
Lime creates an unsaved destination placement matching the source tile/float
kind. The old Head loses the client and focuses a surviving window. The low-level
`backend-transfer-focused-client-*` functions remain strict, non-prompting lease
transfers; `backend-rehome-client-to-head` is the separate explicit rehome API. Lime
does not yet have dwm's all-floating layout; floating remains per-client.
The retained manual tree is accessible through Lisp and existing Views,
not through the everyday layout cycle.
For names that only you can choose, use the configuration helpers. The strict
View policy below is safe by default; a future picker can use the same
preflight result to offer partial or take alternatives:
```lisp
;; Super is normally Mod4 on X11.
(lime.x11:bind-head-key *lime* #\1 "left")
(lime.x11:bind-head-key *lime* #\2 "right")
;; A View binding chooses an explicit collision policy.
(lime.x11:bind-view-key *lime* #\d "docs" :policy :strict)
;; A Viewset binding atomically applies its saved Head -> View mapping.
(lime.x11:bind-viewset-key *lime* #\w "work-desk" :policy :strict)
;; Without :VIEW-ID this resumes the Activity's unsaved Head-local draft,
;; creating a blank draft on that Head when needed.
(lime.x11:bind-activity-key *lime* #\g "game" :view-id "play")
(lime.x11:list-keybindings *lime*)
(lime.x11:clear-keybindings *lime*)
```
Handlers that signal an error—such as a strict activation blocked by a
lease—do not kill the event loop. Inspect `backend-last-command-error` from
the REPL, and use `clear-keybindings` during reconfiguration. `bind-key`
remains available for arbitrary commands. The first pass matches modifier
state exactly, so Caps Lock/Num Lock tolerance and an actual focus policy
remain later input-layer work.
## Open the first command palette
Press Super-y to open a which-key-style command map below the bar. It shows
only the current prefix's keys in a compact grid; `+` labels are submenus.
Press a shown key to enter a group or run a command. Space or Tab opens direct
search; type to filter, use Up/Down to select, and Enter to run. Tab in search
returns to the root map. Escape closes in every mode. Super-p remains the
separate fuzzy program launcher. This is a real X11 overlay with a temporary keyboard grab,
not a managed client, so it is local to the currently focused Head and does
not affect client leases by itself.
The launcher reuses its executable catalog while PATH directories are unchanged;
rapid typing immediately after the shortcut is queued for the picker, not sent
to the previously focused application. The isolated `test/x11-launcher-smoke.lisp`
checks complete shortcut-plus-query-plus-Enter bursts and keyboard error cleanup.
The direct-search catalog includes `New named Activity...`, `Save current
arrangement as View...`, and—when every configured Head has a saved source
View—`Save current Heads as Viewset...`. Press Enter on either to enter a one-line name prompt;
Enter confirms it and Esc cancels it. A named Activity opens a blank unsaved
live draft; it owns no permanent View until you save a meaningful checkpoint.
Saving a View checkpoints the live draft and mounts that new checkpoint, so
Super-Shift-s subsequently updates the View you just named.
Lime derives readable durable ids (`WM Project` becomes `wm-project`; `Code`
within it becomes `wm-project/code`) and adds a numeric suffix if needed, while
keeping your original title as the display name.
If RandR reports an added or removed Head, the searchable catalog also
contains `Reconcile changed displays...`. Selecting it asks once for every
live Presentation on a removed Head: move it to one of the discovered Heads,
or `Suspend safely`. The last screen summarizes every move and suspension;
`Apply display plan` is the only action that calls the topology command. Thus
an unplug event never grabs attention or moves a task until you deliberately
open the palette and confirm the plan.
The map shows a breadcrumb instead of a search input. Backspace moves up one
level; PageUp/PageDown reveal additional keys if a large custom tree or narrow
Head requires pages. The panel sizes itself to the current entries and follows
the configured font and bar colors. The first level is deliberately small:
| Key | Menu |
| --- | --- |
| a | Activity (`a n` creates a named Activity; `a o` searches) |
| v | View (`v s` saves; `v u` updates; `v o` searches) |
| x | Viewset (`x s` saves current Heads; `x o` searches) |
| l | Layout (master/stack, monocle, cycle) |
| w | Window (focus, float, close) |
| h | Head (select, send a client, or `h t` reconcile changed displays) |
| r | Launch (terminal, browser, explicit shell command) |
| q | System (`q q` requests a graceful quit) |
| u | User (when the config supplies commands with `:key`) |
| Space | Search all commands |
For example, `Super-y`, `v`, `s` reaches the same new-View name prompt
without typing a search query. The key tree only calls the existing command
layer, so it carries no independent Activity, layout, or lease policy.
When a display change is pending, `Super-y`, `h`, `t` reaches exactly
the same reconciliation flow as the direct-search candidate.
`Super-y → a → d` opens **Delete activity...** (also available in direct search).
First move or close every window owned by the Activity, including hidden/shared
ones, and switch all Heads away from it. The picker explains either blocker.
Choose the Activity, then explicitly select **Delete Activity: name**; the
confirmation defaults to Cancel. This removes its saved Views and cached drafts,
prunes their Viewset entries, and deletes any now-empty Viewsets. No app closes
and no Head switches. There is no undo; a changed desk invalidates the confirmation
and requires reviewing it again. Deletion is saved by ordinary session persistence.
Selecting a ready View uses the normal strict activation command. Selecting a
View whose windows are leased on another Head replaces the result list with
three explicit options:
| Choice | Command it runs |
| --- | --- |
| Recall without busy windows | `backend-activate-view-partially` |
| Move windows here | `backend-activate-view-taking` |
| Cancel | No model transition |
The palette includes `Update saved view...` when the current Activity has saved
checkpoints, even if the live arrangement has no source View. Choose a target
and confirm its name before replacing it; Cancel is the default choice.
`Recall view...` explicitly restores a checkpoint, including the last one used.
Named-entity pickers, lease-conflict choices, and name
prompts remain separate transitions inside the same overlay; the command map
does not force those choices into a giant tree. Returning to the map and
searching again restores the full catalog, not the last scoped picker.
## What the adapter does
`start-backend` creates one Head for each configured rectangle and an
initial `inbox` Activity/View for each, claims the root window, and adopts
already-viewable normal clients into the selected Head. Each normal
`MapRequest` then:
1. creates a Lime client in the active Presentation's Activity if necessary;
2. adds it only to the active Presentation's live draft;
3. gives it a lease when free; and
4. resolves the Head-local layout tree into X11 work-area rectangles before mapping
the leased clients; and
5. focuses the selected Head's preferred visible client (or root if blank).
`apply-active-presentation` is the narrow convenience boundary around a
declarative render plan and its X11 executor. `build-render-plan` produces
inspectable configure/map/unmap/raise/focus effects for every configured Head;
`execute-render-plan` flushes them as one CLX batch and refuses a stale world
revision. `DestroyNotify` lifecycle handling retires a permanently gone client
from the model before the remaining geometry is reapplied. See
[the architecture document](architecture.md) for the module contracts.
## Intentional omissions
This initial bridge is purposefully not a complete ICCCM/EWMH-compliant
daily-driver.
It currently has no per-client decorations, theme system, custom/dynamic
key-path palettes, focus-follows-pointer policy, aspect-ratio hints, general
floating rules or stacking controls, session persistence, or complete
View/Viewset editing UI. It implements a small ICCCM slice: basic client facts,
`WM_DELETE_WINDOW`, InputHint/`WM_TAKE_FOCUS`, transient-dialog placement,
min/max/base/increment normal hints, and synthetic ConfigureNotify for managed
ConfigureRequests. Its small EWMH slice advertises `_NET_SUPPORTED`, handles
fullscreen/activate/close requests, identifies itself, publishes client and
stacking lists, and maintains `_NET_ACTIVE_WINDOW`; it does not provide fake
`_NET_WM_DESKTOP` state, external dock work areas, or the many other EWMH
states expected of a mature desktop WM.
Those are separate layers. The value of this adapter is that the core model
can now be observed controlling real windows while those layers are added
without smuggling policy into lease resolution.