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

@ -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