Watch
1
0
Fork
You've already forked souveraine
0

sessiond: touch gestures are a verb, so the compositor stops deciding

viewtop recognises three fingers and had nowhere to send it. The obvious
move — have the compositor call `qs ipc call overview toggle` itself —
is the eighth blind actor §12 forbids by name, and wiuf-vpn-gate is what
that costs: 652 tunnel recycles in ninety minutes with no way to turn it
off.

So `gesture` joins `button` on the socket. The asymmetry between them is
the interesting part and is written down where it happens: `button`
carries raw edges because recognition is an accumulation over time and
the machine owns time, while `gesture` arrives already named, because
touch contacts exist only inside the compositor and nothing else CAN
recognise them. The line §12 draws still holds — the compositor names
what the fingers did, the machine decides what it means, and behaviour
leaves through the one executor table.

Three fingers raise the overview. Everything else is recognised,
recorded and inert, which is deliberate: a gesture that fires something
nobody chose is worse than one that fires nothing. The real binding
table belongs in DeviceStatePolicy, persisted and agent-writable.

Not on a dark panel. The overview is content, and content on an
unauthenticated glass is what the disclosure rules exist to prevent — a
tap on a dark panel is a wake, and that is the power button's business.

The reply says whether the gesture was bound, so a caller can tell "the
machine had no binding" from "the machine did not understand".
This commit is contained in:
Fimeg 2026-08-03 20:53:18 -04:00
commit 91ea54ebf5
3 changed files with 156 additions and 4 deletions

View file

@ -22,8 +22,7 @@ use tracing::{info, warn};
use crate::sessiond::bearer::{Bearer, BearerEvidence}; use crate::sessiond::bearer::{Bearer, BearerEvidence};
use crate::sessiond::protocol::{ use crate::sessiond::protocol::{
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture,};
};
/// How long a locked, lit panel waits for input before it blanks. /// How long a locked, lit panel waits for input before it blanks.
/// ///
@ -409,6 +408,20 @@ pub enum Action {
/// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the /// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the
/// panel, only sessiond moves the lock". /// panel, only sessiond moves the lock".
Unblank, Unblank,
/// Raise the window overview.
///
/// A touch gesture bound the way every other control is: viewtop reports
/// what the fingers did, the machine decides what it means, and the
/// behaviour leaves here through the executor table. §12 is explicit that
/// the alternative — the compositor recognising three fingers and calling
/// `qs ipc` itself — is the eighth blind actor, and `wiuf-vpn-gate` is what
/// that costs: 652 tunnel recycles in ninety minutes with no way to turn it
/// off.
///
/// The compositor has to do the *recognition* because contacts only exist
/// there, which is why this arrives already named rather than as raw
/// touches. What it must not do is decide what a name means.
Overview,
/// Request the session lock, because something wants the panel dark and /// Request the session lock, because something wants the panel dark and
/// the session is not locked yet. /// the session is not locked yet.
/// ///
@ -1680,6 +1693,38 @@ impl DeviceStateMachine {
) )
} }
/// Bind a recognised touch gesture to behaviour.
///
/// The same shape as [`Self::apply_gesture`] for buttons, and deliberately
/// as small: three fingers raise the overview, everything else is
/// recognised, recorded and inert. A gesture that fires something nobody
/// chose is worse than one that fires nothing, and the binding table this
/// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and
/// agent-writable, so a triple-tap can be re-pointed without a rebuild.
pub fn touch_gesture(&mut self, fingers: u8, gesture: TouchGesture) -> Vec<Action> {
let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap);
self.record_decision(
"touch-gesture",
serde_json::json!({
"fingers": fingers,
"gesture": gesture.as_str(),
"bound": bound,
}),
"touch gesture recognised",
);
if !bound {
return Vec::new();
}
// Not while the screen is dark: the overview is content, and putting
// content on an unauthenticated glass is what §4's disclosure rules
// exist to prevent. A tap on a dark panel is a wake, and that is the
// power button's business, not this one's.
if !self.panel_on {
return Vec::new();
}
vec![Action::Overview]
}
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> { pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
// Buttons first: a hold that crossed its threshold during this tick // Buttons first: a hold that crossed its threshold during this tick
// should be announced before the idle rules below decide anything, or // should be announced before the idle rules below decide anything, or
@ -3146,6 +3191,45 @@ mod tests {
} }
} }
#[test]
fn three_fingers_raise_the_overview() {
let (mut sm, _t0) = unlocked_and_lit();
let actions = sm.touch_gesture(3, TouchGesture::Tap);
assert_eq!(actions, vec![Action::Overview]);
}
#[test]
fn every_other_touch_gesture_is_recognised_and_inert() {
// Deliberate. A gesture that fires something nobody chose is worse
// than one that fires nothing, and the binding table this is a
// placeholder for is DeviceStatePolicy's to hold.
let (mut sm, _t0) = unlocked_and_lit();
for g in [
TouchGesture::SwipeUp,
TouchGesture::SwipeDown,
TouchGesture::SwipeLeft,
TouchGesture::SwipeRight,
] {
assert!(sm.touch_gesture(3, g).is_empty(), "{g:?}");
}
for fingers in [1u8, 2, 4, 5] {
assert!(
sm.touch_gesture(fingers, TouchGesture::Tap).is_empty(),
"{fingers} fingers"
);
}
}
#[test]
fn the_overview_does_not_open_on_a_dark_panel() {
// The overview is content, and content on an unauthenticated glass is
// what the disclosure rules exist to prevent. A tap on a dark panel is
// a wake, and that is the power button's business.
let (mut sm, _t0) = unlocked_and_lit();
sm.set_panel(false);
assert!(sm.touch_gesture(3, TouchGesture::Tap).is_empty());
}
#[test] #[test]
fn a_press_that_woke_the_panel_does_not_then_blank_it() { fn a_press_that_woke_the_panel_does_not_then_blank_it() {
// The regression Casey hit: press to wake, the lock screen appears, and // The regression Casey hit: press to wake, the lock screen appears, and

View file

@ -181,6 +181,13 @@ pub const VERBS: &[VerbDoc] = &[
refuses: &[RefusalCode::InvalidArgument], refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"button","button":"power","edge":"down"}"#, example: r#"{"op":"button","button":"power","edge":"down"}"#,
}, },
VerbDoc {
op: "gesture",
mutates: true,
summary: "a recognised touch gesture; the machine decides what it means",
refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"gesture","fingers":3,"gesture":"tap"}"#,
},
VerbDoc { VerbDoc {
op: "panel", op: "panel",
mutates: true, mutates: true,
@ -300,6 +307,15 @@ pub enum Request {
/// hold — happens in the machine, because a gesture is an accumulation over /// hold — happens in the machine, because a gesture is an accumulation over
/// time and time is the one thing a fire-and-forget script does not have. /// time and time is the one thing a fire-and-forget script does not have.
Button { button: Button, edge: ButtonEdge }, Button { button: Button, edge: ButtonEdge },
/// A recognised touch gesture, already named by the compositor.
///
/// Unlike [`Self::Button`], which carries raw edges because recognition is
/// an accumulation over time and the machine owns time, this arrives named:
/// touch contacts exist only inside the compositor, so nothing else *can*
/// recognise them. The line §12 draws still holds — the compositor names
/// what the fingers did and the machine decides what it means. A compositor
/// that both recognised and acted would be the eighth blind actor.
Gesture { fingers: u8, gesture: TouchGesture },
/// The DPMS executor reports the panel's real power state. The machine /// The DPMS executor reports the panel's real power state. The machine
/// keeps the panel as a field, not a state: "locked with the screen off" /// keeps the panel as a field, not a state: "locked with the screen off"
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark /// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
@ -429,6 +445,32 @@ pub enum ButtonEdge {
Up, Up,
} }
/// What a set of fingers did. Named by the compositor, meant by the machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TouchGesture {
/// Down and up without travelling.
Tap,
/// Travelled and let go. The direction is what a binding usually cares
/// about; the distance is the compositor's business.
SwipeUp,
SwipeDown,
SwipeLeft,
SwipeRight,
}
impl TouchGesture {
pub fn as_str(self) -> &'static str {
match self {
TouchGesture::Tap => "tap",
TouchGesture::SwipeUp => "swipe_up",
TouchGesture::SwipeDown => "swipe_down",
TouchGesture::SwipeLeft => "swipe_left",
TouchGesture::SwipeRight => "swipe_right",
}
}
}
/// What the machine made of a run of edges. /// What the machine made of a run of edges.
/// ///
/// `Hold` fires while the button is still down — a hold you only learn about on /// `Hold` fires while the button is still down — a hold you only learn about on
@ -436,7 +478,7 @@ pub enum ButtonEdge {
/// waiting with no feedback is how a user decides the device is broken and lets /// waiting with no feedback is how a user decides the device is broken and lets
/// go. Taps resolve on the multi-tap window expiring, which is the opposite /// go. Taps resolve on the multi-tap window expiring, which is the opposite
/// trade and the right one: a double-tap must not first fire a single. /// trade and the right one: a double-tap must not first fire a single.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ButtonGesture { pub enum ButtonGesture {
Tap, Tap,
@ -556,7 +598,10 @@ mod tests {
// And the other direction: every variant must be advertised. Bump this // And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc. // deliberately when a verb is added, having added its VerbDoc.
assert_eq!(VERBS.len(), 16, "a Request variant was added without a VerbDoc"); // 17 since `gesture` (2026-08-03): the compositor names what the
// fingers did and the machine decides what it means, so the naming had
// to become a verb rather than the compositor calling a tool itself.
assert_eq!(VERBS.len(), 17, "a Request variant was added without a VerbDoc");
} }
#[test] #[test]

View file

@ -372,6 +372,14 @@ fn execute(shared: &Arc<Shared>, action: Action) {
vec!["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"], vec!["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"],
"volume-down", "volume-down",
), ),
// The shell owns the overview; the machine owns what opens it. Same
// shape as every other row here — a named tool with named arguments,
// one place, in the trail.
Action::Overview => (
"qs",
vec!["-c", "souveraine", "ipc", "call", "overview", "toggle"],
"overview-toggle",
),
Action::Blank => { Action::Blank => {
// The invariant, enforced where it cannot be reasoned around: the // The invariant, enforced where it cannot be reasoned around: the
// panel does not go dark unless logind says this session is // panel does not go dark unless logind says this session is
@ -1248,6 +1256,21 @@ fn handle_request(
} }
serde_json::json!({ "ok": true }) serde_json::json!({ "ok": true })
} }
Request::Gesture { fingers, gesture } => {
let actions = {
let mut d = shared.lock();
d.device_state.touch_gesture(fingers, gesture)
};
let bound = !actions.is_empty();
for action in actions {
execute(shared, action);
}
// Whether it meant anything, reported. A gesture the machine
// recognises and has no binding for is a legitimate answer and a
// different one from a gesture it did not understand — the caller
// can tell them apart instead of guessing.
serde_json::json!({ "ok": true, "bound": bound })
}
Request::Panel { on } => { Request::Panel { on } => {
let actions = { let actions = {
let mut d = shared.lock(); let mut d = shared.lock();