← Otto Developer GuideSide canvas

The side canvas is a column that slides in over the desktop from the right edge of an output. Otto owns the column; clients put surfaces in it through otto-canvas-v1. The behaviour contract is in specs/side-canvas.md; this page covers how it is built.

Edge swipe input

On the udev backend a two-finger scroll that starts at the right edge of the touchpad and moves left opens the canvas, following the fingers. While the canvas is shown, a two-finger scroll to the right that starts away from that edge closes it. The code lives in src/input/edge_swipe/, with the glue into the libinput event stream in src/udev/edge_swipe.rs; it is compiled only with the udev feature, since winit, X11 and headless have no evdev devices.

Where the finger positions come from

libinput reports neither finger positions nor where a scroll began, so the touch origins are read from the kernel’s multitouch slot state.

Edge zone

The rightmost touch origin has to be within 12 mm of the right edge and the second one within 40 mm; further touches, such as a resting thumb, are ignored. Pads that report no resolution use 10 % and 35 % of the axis range. The constants are in src/input/edge_swipe/mod.rs.

Claiming a scroll

EdgeSwipe is a pure state machine fed one event per finger scroll (AxisSource::Finger), in physical finger terms: the glue flips libinput’s values back when natural scrolling is on, so the setting never changes the gesture’s direction. Deltas are divided by the pointer output’s scale, so the canvas moves in logical points 1:1 with the fingers.

  1. At the first event of a scroll, the canvas state picks a candidate: reveal when the canvas is hidden, canvas_available() and the touches started at the edge; dismiss when the canvas is shown and they did not. Otherwise the whole scroll passes to clients untouched, with no delay.
  2. A candidate’s events are held until the fingers have moved 8 points. If the travel is at least twice as horizontal as vertical and points the right way (left to reveal, right to dismiss) the scroll is claimed: canvas_gesture_begin plus an update with the travel so far, and the held events are dropped. Otherwise the held events are replayed to clients in order and the rest of the scroll passes through. A scroll that has not covered 8 points after 150 ms, or ends before, is released too.
  3. A claimed scroll feeds canvas_gesture_update (positive reveals) and is swallowed. The axis-stop event (every axis zero: fingers lifted) calls canvas_gesture_end with the average speed over the last 100 ms, so fingers that pause before lifting settle without a fling.
  4. A gesture begin or a removed touchpad ends a claimed swipe as cancelled. Any other touchpad event arriving while a scroll is held releases the held events ahead of it.

The 3-finger workspace swipe, pinch and hold gestures are libinput gesture events and never reach the state machine as scrolls.

Known limitations

Protocol

protocols/otto-canvas-v1.xml has two interfaces:

Otto advertises version 5. otto-kit binds 1..=5, and each CanvasItemSurface method for a newer request (set_keyboard_interactivity, show, set_order, set_content_height) sends it only when the bound version has it, returning false otherwise, so a new client keeps running against an older compositor (an unknown request would get it disconnected). Clients that speak the wire directly, like otto-stash, gate the same way on the bound version.

The server side is src/otto_canvas/: protocol.rs generates the bindings, handlers.rs holds the GlobalDispatch/Dispatch impls (CanvasGlobal), allot.rs shares the column’s height, and mod.rs the state and behaviour. The client side is otto_kit::surfaces::CanvasItemSurface, which acks configures, creates its Skia surface on the first one and forwards every event to an on_event handler. Apps hear of drags through App::on_canvas_drag_started(ctx, mime_types) and App::on_canvas_drag_ended(ctx); AppData gathers the drag_mime_type events until drag_started.

Scene

overlay_plane_{output}
├── … layer_shell_top, layer_shell_overlay, dock_plane (primary only)
├── canvas_plane_{output}         hidden unless the canvas is on this output
│   └── canvas_column             positioned and clipped by the canvas
│       ├── canvas_slot           one per item, stacked by the canvas
│       │   └── canvas_item       the client's surface layer (surface_layers)
│       └── …
├── workspace_selector_{output}
├── overlay_layer (primary only)
└── popup_overlay (primary only)

Sharing the height

src/otto_canvas/allot.rs holds the pure share function, allot(available, gap, &[Demand]) -> Vec<u32>, with its unit tests. A Demand is Content(h) for an item that takes part or Fixed(h) for one that does not. It takes the gaps between showing items (height > 0) and the fixed heights off the column, then water-fills the rest: contents under the level keep their height, the others get the level, and the points the division leaves go to the topmost items at the level. MIN_SHARE (120 pt, or the content if less) is what fixed items can never squeeze an item below; when they leave less, each item gets its minimum and the column overflows, and a column too short for the minimums alone is water-filled whole.

Otto::canvas_share_height builds the demands from CanvasItem (content_height from set_content_height, otherwise the buffer height from item_size_points), and sends max_height to each version 5 item whose share differs from the max_height it was last sent. An item that has not sent a content height is offered what allot would give it wanting the whole column. The height is canvas_available_height(): the column geometry (canvas_geometry_on) of the canvas’s output, or of the output under the pointer or the primary one while hidden, in whole points.

It runs from canvas_relayout (every item commit, set_order, a removed item, a width or config change, and canvas_bring_on_screen before shown), from canvas_item_created between configure and shown/hidden, from set_content_height, and from layer_zones_changed when an output’s usable area moves. Stacking still uses buffer heights, so an item that has not yet resized to its share overflows for a frame rather than being squeezed by the compositor.

On the client side CanvasItemSurface::set_content_height sends only a changed height, max_height() holds the last share (CanvasItemEvent::MaxHeight reaches the handler after it is stored), and fit_height(content, fallback_max) is min(content, share), using fallback_max until a share arrives or below version 5. shares_height() says whether one will come; the Agents panel does not draw until it has.

State and animation

CanvasState (on Otto as canvas) holds the items and a phase: Hidden, Dragging { was_shown }, Shown and Closing { generation }.

Input

Drag and drop

src/otto_canvas/drag.rs holds the decisions as plain functions and types, unit tested on their own: in_open_zone (the last OPEN_FRACTION, a tenth, of the output’s width), PeekFollow (how much of the column is on screen: PEEK_MIN 0.1 up to the middle of the output, FOLLOW_FROM, growing to PEEK_MAX 1.0 where the open zone starts; TurnedAway once the pointer goes TURN_FRACTION of the output left of its furthest past the middle, after which it stays at PEEK_MIN until come_back), left_of_open_column (the pointer TURN_FRACTION of the output left of an open column), EdgeDwell (fires once per rest of a given wait, OPEN_DWELL, 150 ms), hide_when_drag_ends and hide_on_release. mod.rs applies them in canvas_drag_tick, called when the drag starts and then every POLL: a hidden canvas is put in Phase::Peeking (canvas_peek, retried until an item exists), which follows the pointer (canvas_peek_follow, eased over PEEK_FOLLOW_DURATION, or PEEK_TURN_DURATION on a turn) and moves to the pointer’s output; a rest in the open zone opens it (canvas_show_passive), or, once turned away, a rest over the column (canvas_column_rect). A canvas the drag opened goes back to peeking (canvas_back_to_peek) when the pointer is left_of_open_column. The amounts on screen become progress through ColumnGeometry::progress_showing, which counts from the output’s edge rather than from the hidden position a bleed past it.

Frame callbacks

Canvas items are in no space and no layer map, so post_repaint never sees them. canvas_frame_presented sends their frame callbacks while the canvas is on screen on that output, and nothing while it is hidden. The preferred fractional scale is set when an item is created and whenever the canvas comes on screen.

Configuration

[canvas] holds width, margin and gap (logical points). canvas.width is a live setting: apply_live calls canvas_config_changed, which sends every item a new configure and lays the column out again.

Stash card

components/otto-stash binds otto_canvas_manager_v1 at version 3 to 5 when the compositor offers it (it needs show, set_order and never) and then hosts its “Ask about…” card as a canvas item (otto-stash/src/canvas.rs) instead of the floating overlay balloon (panel.rs). card.rs holds the Card enum over the two. otto-stash is a plain wayland-client program, not an otto-kit app, so it speaks the protocol through otto_kit::protocols rather than CanvasItemSurface.

The canvas card sets order -100 (above the Agents panel), never keyboard interactivity, and the launcher’s frost through otto-surface-style without the balloon’s shadow or position. It draws with balloon.rs at the configured width. At version 5 it sends its natural height (Layout::natural_height, the card with every item showing) with set_content_height whenever a layout changes it, counts as configured only once the first max_height has arrived, and lays out again on each new max_height, scrolling the items past it; below version 5 it grows up to 640 points. After an add, or when the card appears, it sends show once the new buffer is committed. It requests frame callbacks only while shown; on hidden an item shrinking away is removed at once. When the stash ends, or Ask holds it, the item is destroyed and the canvas otherwise stays as it is. A region pick sends dismiss first so the canvas is not in the capture. See specs/stash.md.

At version 4, invite.rs adds a drop invitation on drag_started when the drag may carry files (text/uri-list, or no types) and there is no card: a 72-point item at the card’s order (at version 5 it sends 72 as its content height, below any minimum share, and ignores max_height) with a dashed outline and the stash-drop-invite string, accented while a drag is over it. drop.rs accepts file drops on it as on the card and marks it dropped. On drag_ended an invitation nothing was dropped on is destroyed; one that took files stays until the new card’s first buffer is committed (draw_panel), or until the files are read if no card is coming (settle_invite), so the column never empties in between and the canvas does not close under the drop.

Sessions client and autostart

components/otto-canvas is an otto-kit app that places one frosted item: the agent sessions list. Like otto-bar and otto-islands it is started from [[exec_once]] in the shipped config and installed by the packaging lists.

It shares its pieces with the launcher’s agents mode through components/otto-agents-kit, which both depend on:

The item draws immediate-mode into its one surface: the heading band, the prompt field (the launcher’s TextInput with field_style at 16 pt), a hairline (rows::divider_color), then the rows, with otto-kit’s ScrollView for the scroll physics and scrollbar. Scroll animation steps only when CanvasItemSurface::frame_in_flight is false. The field’s caret shows while CanvasItemSurface::has_keyboard is true and blinks on the idle timeout. The item asks for on_show when it is created. Opening a row spawns otto-launcher --session <URI>, and Enter or Ctrl+L with text typed spawns otto-launcher --ask --send -- <text> (the launcher takes everything after -- as its query, and --send sends it as soon as the card is up); Ctrl+L with nothing typed spawns otto-launcher --ask. Each is reaped on a thread and followed by dismiss.

Tests

Follow-ups