diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index fb6fd33..e0e090a 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -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 diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionAction.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionAction.qml new file mode 100644 index 0000000..8c814d4 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionAction.qml @@ -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 + } + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionActions.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionActions.qml new file mode 100644 index 0000000..4f281fb --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionActions.qml @@ -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 } +} diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionChip.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionChip.qml new file mode 100644 index 0000000..4148f57 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionChip.qml @@ -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 + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionHost.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionHost.qml new file mode 100644 index 0000000..9f24f92 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionHost.qml @@ -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(); + } + } + } + } + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/selection/SelectionSeparator.qml b/surfaces/quickshell/modules/souveraine/selection/SelectionSeparator.qml new file mode 100644 index 0000000..9f1fa8a --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/SelectionSeparator.qml @@ -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 +} diff --git a/surfaces/quickshell/modules/souveraine/selection/qmldir b/surfaces/quickshell/modules/souveraine/selection/qmldir new file mode 100644 index 0000000..d8d5799 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/selection/qmldir @@ -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 diff --git a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml index 326d37d..82e2063 100644 --- a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml +++ b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml @@ -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" diff --git a/surfaces/quickshell/services/DeviceEvidence.qml b/surfaces/quickshell/services/DeviceEvidence.qml new file mode 100644 index 0000000..3788c01 --- /dev/null +++ b/surfaces/quickshell/services/DeviceEvidence.qml @@ -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. + } + } + } + } +} diff --git a/surfaces/quickshell/services/qmldir b/surfaces/quickshell/services/qmldir index 0310a16..bc4d571 100644 --- a/surfaces/quickshell/services/qmldir +++ b/surfaces/quickshell/services/qmldir @@ -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