Watch
1
0
Fork
You've already forked souveraine
0

Selection menu surface: chip that expands to actions (TASK-18)

Overlay layer following DialHost, keyboardFocus None throughout, and an input
mask limited to the card so taps elsewhere reach the app underneath. Anchors to
the pointer hint above the touch point, clamped on screen.

Chip shows a character count, never a preview: the surface floats over the app
that owns the selection and the content may be a password, so it stays ambient
and discloses nothing. Read Aloud is live via Speech; agent and reference
actions render with the reason they cannot act yet.

Adds DeviceEvidence, reporting input to the state machine's existing
Request::Input with an intent label, so a new input surface is not another
isolated actor per DEVICE-STATE-MACHINE 1. Opt-in: nothing loads or watches
until Config.options.selection.enable.
This commit is contained in:
Fimeg 2026-07-29 17:47:20 -04:00
commit 0bebc779c6
10 changed files with 606 additions and 0 deletions

View file

@ -38,10 +38,18 @@ services/Ai.qml souveraine/services/Ai.qml
services/Cellular.qml souveraine/services/Cellular.qml
services/ChargeRate.qml souveraine/services/ChargeRate.qml
services/Haptics.qml souveraine/services/Haptics.qml
services/Selection.qml souveraine/services/Selection.qml
services/DeviceEvidence.qml souveraine/services/DeviceEvidence.qml
services/Gestures.qml souveraine/services/Gestures.qml
modules/souveraine/dial/RadialDial.qml souveraine/modules/souveraine/dial/RadialDial.qml
modules/souveraine/dial/DialHost.qml souveraine/modules/souveraine/dial/DialHost.qml
modules/souveraine/dial/qmldir souveraine/modules/souveraine/dial/qmldir
modules/souveraine/selection/SelectionHost.qml souveraine/modules/souveraine/selection/SelectionHost.qml
modules/souveraine/selection/SelectionChip.qml souveraine/modules/souveraine/selection/SelectionChip.qml
modules/souveraine/selection/SelectionActions.qml souveraine/modules/souveraine/selection/SelectionActions.qml
modules/souveraine/selection/SelectionAction.qml souveraine/modules/souveraine/selection/SelectionAction.qml
modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml
modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/qmldir
services/Network.qml souveraine/services/Network.qml
services/TaskbarApps.qml souveraine/services/TaskbarApps.qml
services/GlobalFocusGrab.qml souveraine/services/GlobalFocusGrab.qml

View file

@ -0,0 +1,79 @@
// One row in the selection menu.
//
// A disabled row still renders and still explains itself the same choice
// RadialDial makes with its `reason` in the middle of the ring. A greyed-out
// entry teaches nothing; a row that says "Needs the side-shoot seam in Ai.qml"
// tells you what is missing.
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
Item {
id: root
property string icon: "radio_button_unchecked"
property string label: ""
property string sublabel: ""
/** LockContentPolicy tier this action's effect belongs to. */
property string tier: LockContentPolicy.ambient
/** Why this is unavailable. Shown in place of the sublabel when disabled. */
property string reason: ""
// `enabled` is the Item property; a disabled row is visible but inert.
signal triggered()
Layout.fillWidth: true
implicitHeight: root.sublabel.length > 0 || (!root.enabled && root.reason.length > 0)
? 56 : 44
MouseArea {
anchors.fill: parent
enabled: root.enabled
onClicked: {
Haptics.trigger("button-pressed");
root.triggered();
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 12
MaterialSymbol {
text: root.icon
iconSize: 20
opacity: root.enabled ? 1.0 : 0.4
color: Appearance.colors.colOnLayer1
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
StyledText {
Layout.fillWidth: true
text: root.label
elide: Text.ElideRight
opacity: root.enabled ? 1.0 : 0.4
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.small
}
StyledText {
Layout.fillWidth: true
visible: text.length > 0
// The refusal replaces the description: when a row cannot act,
// what it would have done is less useful than why it cannot.
text: root.enabled ? root.sublabel : root.reason
elide: Text.ElideRight
wrapMode: Text.NoWrap
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
}
}

View file

@ -0,0 +1,134 @@
// The expanded state: the action list.
//
// Grouping follows TASK-18's sections, and its stated default for progressive
// disclosure: Read Aloud and the agent actions at this level, Reference behind
// "More" because the full set is long for a phone screen.
//
// Tiers (SESSION-AUTHORITY §2) are enforced here rather than assumed. The whole
// surface is already unreachable on the lock screen Selection.qml kills its
// watcher unless the session is genuinely unlocked but each action still
// declares its own tier so the rule is legible where the action lives, and so
// this file stays correct if the surface is ever reached another way.
//
// An action with no backend says so. TASK-18: "A menu whose actions are stubs is
// acceptable; a janky menu is not" but a stub that looks live and does nothing
// is worse than either, so `reason` is shown rather than a dead tap.
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import qs
import qs.services
import qs.modules.common
ColumnLayout {
id: root
signal acted()
property bool showReference: false
readonly property string tierAmbient: LockContentPolicy.ambient
readonly property string tierPersonal: LockContentPolicy.personal
implicitWidth: 264
spacing: 0
// Read Aloud the one live backend
// The literal selected text, no agent ingestion. Routes through
// Speech.speak(), which reads the who->voice mapping from souveraine's
// /v1/config, so the shell still picks no voice of its own.
SelectionAction {
icon: "volume_up"
label: Translation.tr("Read aloud")
tier: root.tierAmbient
enabled: Config.options.selection.readAloud && Speech.enabled
reason: !Config.options.selection.readAloud
? Translation.tr("Turned off in Settings")
: Translation.tr("Enable TTS in Settings → Speech")
onTriggered: {
Speech.speak(Selection.text);
root.acted();
}
}
SelectionSeparator { visible: Config.options.selection.agentActions }
// Agent actions
// Both reach conversation history, which is `personal`. TASK-18 §2 is the
// spec for the distinction and it is the same one the OCR pipeline wants:
// one seeds a side-shoot, the other appends to the primary conversation.
SelectionAction {
visible: Config.options.selection.agentActions
icon: "forum"
label: Translation.tr("Talk about this")
sublabel: Translation.tr("Starts a side conversation")
tier: root.tierPersonal
enabled: false
reason: Translation.tr("Needs the side-shoot seam in Ai.qml")
onTriggered: root.acted()
}
SelectionAction {
visible: Config.options.selection.agentActions
icon: "add_comment"
label: Translation.tr("Add to our conversation")
sublabel: Translation.tr("Appends to the current thread")
tier: root.tierPersonal
enabled: false
reason: Translation.tr("Needs the primary-conversation seam in Ai.qml")
onTriggered: root.acted()
}
SelectionSeparator {}
// Utility
SelectionAction {
icon: "content_copy"
label: Translation.tr("Copy")
tier: root.tierAmbient
// The selection is already in the primary buffer; this promotes it to
// the clipboard, which is what a phone user means by "copy".
onTriggered: {
copyProc.command = ["sh", "-c",
"WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-1} " +
"wl-paste --primary --no-newline | wl-copy"];
copyProc.running = true;
root.acted();
}
}
SelectionSeparator { visible: Config.options.selection.referenceActions }
// Reference, behind one more tap
SelectionAction {
visible: Config.options.selection.referenceActions && !root.showReference
icon: "more_horiz"
label: Translation.tr("More…")
tier: root.tierAmbient
onTriggered: root.showReference = true
}
// TASK-18's default stance, unchanged: strictly local, never the agent.
// None of these have a local backend on the device yet, so all three say so.
Repeater {
model: root.showReference && Config.options.selection.referenceActions
? [
{ icon: "book_2", label: Translation.tr("Define") },
{ icon: "translate", label: Translation.tr("Translate") },
{ icon: "search", label: Translation.tr("Search the web") },
]
: []
SelectionAction {
required property var modelData
icon: modelData.icon
label: modelData.label
tier: root.tierAmbient
enabled: false
reason: Translation.tr("No local backend yet")
onTriggered: root.acted()
}
}
Process { id: copyProc }
}

View file

@ -0,0 +1,77 @@
// The collapsed state: a small chip that says a selection is live and invites a
// tap. Deliberately does NOT preview the selected text.
//
// A preview would be the obvious design and it is the wrong one here. The chip
// floats over whatever app owns the selection, so a preview duplicates content
// already on screen while adding a surface that can outlive the context it came
// from and the content may be a password. SESSION-AUTHORITY §2 puts that in
// `personal`; showing a count instead keeps the chip `ambient` and means the
// surface itself never discloses anything.
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
RowLayout {
id: root
required property int charCount
signal tapped()
signal dismissed()
implicitHeight: 44
spacing: 0
// Tap target: the whole chip except the dismiss affordance.
Item {
Layout.fillHeight: true
implicitWidth: label.implicitWidth + icon.implicitWidth + 26
MouseArea {
anchors.fill: parent
onClicked: root.tapped()
}
RowLayout {
anchors.centerIn: parent
spacing: 6
MaterialSymbol {
id: icon
text: "text_select_start"
iconSize: 20
color: Appearance.colors.colOnLayer1
}
StyledText {
id: label
text: root.charCount === 1
? Translation.tr("1 character selected")
: Translation.tr("%1 characters selected").arg(root.charCount)
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
}
// Dismiss. Present because the mask means we never see a tap landing
// elsewhere without this the chip's only exit is changing the selection.
Item {
Layout.fillHeight: true
implicitWidth: 40
MouseArea {
anchors.fill: parent
onClicked: root.dismissed()
}
MaterialSymbol {
anchors.centerIn: parent
text: "close"
iconSize: 18
color: Appearance.colors.colSubtext
}
}
}

View file

@ -0,0 +1,154 @@
// The selection action menu's surface (TASK-18).
//
// Two states on one layer surface: a compact chip that appears when a selection
// settles, and the action list it expands into on tap. Apple's shape, and the
// one the protocol allows see Selection.qml for why we get the selected TEXT
// but never its bounding rectangle, and therefore why this anchors to the
// pointer rather than to the highlight.
//
// TASK-18's priority is explicit: the surface must be solid before any backend
// is wired, because "a menu whose actions are stubs is acceptable; a janky menu
// is not." So Read Aloud (the one action with a live backend) is real and the
// rest declare themselves unimplemented rather than failing silently.
//
// Z-order, settled here
// WlrLayer.Overlay, following DialHost: it puts the surface above the OSK's
// layer entirely instead of competing inside Top, which is the collision that
// made the stevia pill/menu bug (TASK-17). keyboardFocus stays None throughout
// taking focus would disturb the source app that owns the selection, and an
// Exclusive grab is the documented way to stop touch reaching the layer below
// (tried on the polkit surface 2026-07-26). Every action here is a tap.
//
// The input mask is load-bearing. This window covers the screen so the chip can
// be placed anywhere, but `mask: Region { item: ... }` narrows the input region
// to the visible card so taps elsewhere reach the app underneath. Note the trap
// documented in OnScreenKeyboard.qml: `Region { item: null }` does NOT mean
// "no input" it behaves like a full-window region and silently eats taps.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs
import qs.services
import qs.modules.common
Scope {
id: scope
// Expanded = the action list is showing. Reset whenever the selection goes.
property bool expanded: false
Connections {
target: Selection
function onSelectionSettled(text, x, y) {
// A new selection always re-collapses: the previous expansion was
// about the previous text.
scope.expanded = false;
}
function onSelectionCleared() {
scope.expanded = false;
}
}
Variants {
model: Quickshell.screens
PanelWindow {
id: win
required property var modelData
screen: win.modelData
anchors { top: true; left: true; right: true; bottom: true }
color: "transparent"
visible: Selection.hasSelection
WlrLayershell.namespace: "souveraine:selection"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
// Only the card is interactive; the rest of the screen is not ours.
mask: Region { item: card }
// Anchor. Selection gives a pointer hint, which is where the finger
// lifted; -1 means it could not be resolved, and centre-bottom is
// the thumb-reachable fallback. Clamped so the card is never partly
// offscreen regardless of where the touch was.
readonly property int pad: 12
readonly property int hintX: Selection.anchorX
readonly property int hintY: Selection.anchorY
readonly property real targetX: {
if (win.hintX < 0) return (win.width - card.width) / 2;
return Math.max(win.pad,
Math.min(win.hintX - card.width / 2,
win.width - card.width - win.pad));
}
readonly property real targetY: {
if (win.hintY < 0) return win.height * 0.72;
// Prefer above the touch point so the finger does not cover the
// menu; flip below when there is no room up there.
const above = win.hintY - card.height - 16;
if (above >= win.pad) return above;
return Math.min(win.hintY + 16, win.height - card.height - win.pad);
}
Item {
id: card
x: win.targetX
y: win.targetY
width: content.implicitWidth
height: content.implicitHeight
// Position eases so a re-selection slides rather than teleports,
// but only once mapped animating from 0,0 on first show reads
// as the menu flying in from the corner.
Behavior on x { enabled: card.opacity > 0; animation: Appearance.animation.elementMove.numberAnimation.createObject(this) }
Behavior on y { enabled: card.opacity > 0; animation: Appearance.animation.elementMove.numberAnimation.createObject(this) }
opacity: Selection.hasSelection ? 1 : 0
scale: Selection.hasSelection ? 1 : 0.88
transformOrigin: Item.Bottom
Behavior on opacity { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
Behavior on scale { animation: Appearance.animation.elementMove.numberAnimation.createObject(this) }
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer1
border.width: 1
border.color: Appearance.colors.colLayer2
}
ColumnLayout {
id: content
anchors.centerIn: parent
spacing: 0
// collapsed: the chip
SelectionChip {
visible: !scope.expanded
charCount: Selection.text.length
onTapped: {
DeviceEvidence.touched("selection-menu");
Haptics.trigger("button-pressed");
scope.expanded = true;
}
onDismissed: {
DeviceEvidence.touched("selection-menu");
Selection.dismiss();
}
}
// expanded: the actions
SelectionActions {
visible: scope.expanded
onActed: {
DeviceEvidence.touched("selection-menu");
Selection.dismiss();
}
}
}
}
}
}
}

View file

@ -0,0 +1,10 @@
// Hairline between action groups.
import QtQuick
import QtQuick.Layouts
import qs.modules.common
Rectangle {
Layout.fillWidth: true
implicitHeight: 1
color: Appearance.colors.colLayer2
}

View file

@ -0,0 +1,5 @@
SelectionHost 1.0 SelectionHost.qml
SelectionChip 1.0 SelectionChip.qml
SelectionActions 1.0 SelectionActions.qml
SelectionAction 1.0 SelectionAction.qml
SelectionSeparator 1.0 SelectionSeparator.qml

View file

@ -28,6 +28,7 @@ import qs.modules.ii.verticalBar
import qs.modules.ii.wallpaperSelector
import qs.modules.souveraine.dial
import qs.modules.souveraine.navigation
import qs.modules.souveraine.selection
// The Souveraine panel family one family for both modes.
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden
@ -64,6 +65,13 @@ Scope {
PanelLoader { component: Overview {} }
PanelLoader { component: Polkit {} }
PanelLoader { component: DialHost {} }
// Text-selection action menu (TASK-18). Gated on the opt-in config: the
// service behind it watches every selection on the device, so it must not
// load by default. See Config.options.selection.
PanelLoader {
extraCondition: Config.options.selection.enable
component: SelectionHost {}
}
// Gestures is a singleton and QML instantiates singletons lazily, so its
// IpcHandler never registered `gesture` answered "Target not found"

View file

@ -0,0 +1,130 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
/**
* DeviceEvidence the shell's ingress to the device state machine.
*
* DEVICE-STATE-MACHINE §1 is a list of seven actors that each saw one facet of
* the device and could not see the others. Every shell surface that takes user
* input is a candidate for becoming an eighth. This singleton exists so that
* "report that the user did something" is one line, and a new surface has no
* excuse to be an isolated unknown.
*
* The wire is `Request::Input { trigger }` (souveraine/src/sessiond/protocol.rs
* that file is the contract): `{"op":"input","trigger":"touch"}`. It resets
* the idle budget the lock/blank rules count against, which is what lets the
* machine distinguish "the user is looking at this" from "this has been lit for
* ten minutes."
*
* `intent` rides along in the Envelope. protocol.rs is explicit that it is
* "declared, never verified... evidence in exactly the sense doctrine §9 means
* useful for reconstruction, never a basis for a decision. Nothing branches on
* it." So it is safe to be honest in, and it is what §11 wants recorded: the
* intent, not only the leaf. Never put user content in it a surface name, not
* what the surface was showing.
*
* No sessiond on the socket (laptop, or bring-up) = every call no-ops quietly.
* This is evidence, not an authority: a dropped report must never be an error
* the user sees.
*/
Singleton {
id: root
// Reports are coalesced: a keyboard would otherwise emit one request per
// keystroke to reset a budget measured in tens of seconds. The machine only
// needs to know the user is still there.
readonly property int _throttleMs: 2000
property double _lastSentAt: 0
property string _pendingTrigger: ""
property string _pendingIntent: ""
/**
* Report real user input.
*
* trigger: "touch" | "key" | "power_button" | "double_tap_to_wake"
* | "squeeze" | "unknown" (InputTrigger, snake_case)
* intent: short surface label, e.g. "selection-menu". No user content.
*/
function report(trigger, intent) {
const t = String(trigger ?? "unknown");
const now = Date.now();
if (now - root._lastSentAt < root._throttleMs) {
// Keep the newest label; the budget reset is idempotent so dropping
// the intervening reports costs nothing.
root._pendingTrigger = t;
root._pendingIntent = String(intent ?? "");
flushTimer.running = true;
return;
}
root._send(t, String(intent ?? ""));
}
/** Convenience for the common case: a tap on one of our own surfaces. */
function touched(intent) {
root.report("touch", intent);
}
Timer {
id: flushTimer
interval: root._throttleMs
repeat: false
onTriggered: {
if (root._pendingTrigger.length === 0) return;
root._send(root._pendingTrigger, root._pendingIntent);
root._pendingTrigger = "";
root._pendingIntent = "";
}
}
property var _queued: null
function _send(trigger, intent) {
root._lastSentAt = Date.now();
const msg = { op: "input", trigger: trigger };
if (intent.length > 0) msg.intent = intent;
if (sock.connected) {
sock.write(JSON.stringify(msg) + "\n");
return;
}
root._queued = msg;
sock.connected = true;
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
onConnectionStateChanged: {
if (connected && root._queued) {
const m = root._queued;
root._queued = null;
sock.write(JSON.stringify(m) + "\n");
} else if (!connected && root._queued) {
// Quiet on purpose. Evidence is best-effort; a missing daemon is
// the laptop's normal state and must not look like a fault.
root._queued = null;
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
// Nothing to do with a reply this is fire-and-forget. Only a
// refusal is worth a line, so a protocol drift is not silent.
try {
const reply = JSON.parse(message);
if (reply.ok !== true)
console.log("[device-evidence] refused:",
reply.code ?? "?", reply.reason ?? "");
} catch (e) {
// Malformed reply is not worth escalating for a fire-and-forget.
}
}
}
}
}

View file

@ -13,6 +13,7 @@ singleton Cliphist 1.0 Cliphist.qml
singleton ConflictKiller 1.0 ConflictKiller.qml
singleton CrashReporter 1.0 CrashReporter.qml
singleton DateTime 1.0 DateTime.qml
singleton DeviceEvidence 1.0 DeviceEvidence.qml
singleton EasyEffects 1.0 EasyEffects.qml
singleton Emojis 1.0 Emojis.qml
singleton FileSearch 1.0 FileSearch.qml