Watch
1
0
Fork
You've already forked souveraine
0

hold power for a device menu; usb-c state shown, not faked

This commit is contained in:
Fimeg 2026-08-05 14:37:56 -04:00
commit f7eba9f658
6 changed files with 248 additions and 1 deletions

View file

@ -428,6 +428,18 @@ pub enum Action {
/// duplicating a gesture the thumb already had. `target` is what makes the /// duplicating a gesture the thumb already had. `target` is what makes the
/// difference — the sheet is *about a window*, and the tap now knows which. /// difference — the sheet is *about a window*, and the tap now knows which.
WindowSheet { target: u64 }, WindowSheet { target: u64 },
/// Raise the power menu — the device's own verbs, on a hold.
///
/// The counterpart to [`Self::WindowSheet`]: that one is about a window,
/// this one is about the device. Restart, power off, and the USB-C port's
/// state — whether it is host or device, whether it is sourcing or sinking
/// power, and what is on the other end.
///
/// Those last ones are here rather than in a settings page because the port
/// is not a preference; it is what the machine currently *is*, and it
/// changes what every other peripheral means. Casey, 2026-08-05: "that
/// whole power usb state probably belongs in the power switch options".
PowerMenu,
/// 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.
/// ///
@ -1665,15 +1677,33 @@ impl DeviceStateMachine {
gesture: ButtonGesture, gesture: ButtonGesture,
now: Instant, now: Instant,
) -> Vec<Action> { ) -> Vec<Action> {
let bound = button == Button::Power
&& matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
self.record_decision( self.record_decision(
"button-gesture", "button-gesture",
serde_json::json!({ serde_json::json!({
"button": button.as_str(), "button": button.as_str(),
"gesture": gesture.as_str(), "gesture": gesture.as_str(),
"bound": button == Button::Power && gesture == ButtonGesture::Tap, "bound": bound,
}), }),
"hardware button recognised", "hardware button recognised",
); );
// Hold raises the power menu — the surface that owns restart, power
// off, and what the USB-C port currently *is* (data role, power
// direction, what is attached). That state is proprioception, not a
// setting, which is why it belongs to this machine and is reached
// through a verb rather than a config page.
//
// Deliberately NOT gated on the panel or the lock. A hold on a dark
// panel is still a request for the menu, and on the lockscreen it must
// still work — powering off is a thing you do to a locked phone, and a
// menu that vanishes when locked would send the user to the hardware
// button they are already holding. Disclosure is unaffected: the menu
// shows device verbs, never session content.
if button == Button::Power && gesture == ButtonGesture::Hold {
self.press_began_dark.remove(&button);
return vec![Action::PowerMenu];
}
if button != Button::Power || gesture != ButtonGesture::Tap { if button != Button::Power || gesture != ButtonGesture::Tap {
return Vec::new(); return Vec::new();
} }
@ -3237,6 +3267,24 @@ mod tests {
assert_eq!(actions, vec![Action::WindowSheet { target: 7 }]); assert_eq!(actions, vec![Action::WindowSheet { target: 7 }]);
} }
#[test]
fn holding_power_raises_the_power_menu() {
let (mut sm, t0) = unlocked_and_lit();
let actions = sm.apply_gesture(Button::Power, ButtonGesture::Hold, t0);
assert_eq!(actions, vec![Action::PowerMenu]);
}
#[test]
fn the_power_menu_still_opens_on_a_dark_panel() {
// Not gated on the panel or the lock: powering off is a thing you do
// to a locked phone, and a menu that vanished when locked would send
// the user back to the button they are already holding.
let (mut sm, t0) = unlocked_and_lit();
sm.set_panel(false);
let actions = sm.apply_gesture(Button::Power, ButtonGesture::Hold, t0);
assert_eq!(actions, vec![Action::PowerMenu]);
}
#[test] #[test]
fn a_three_finger_tap_on_the_wallpaper_raises_nothing() { fn a_three_finger_tap_on_the_wallpaper_raises_nothing() {
// The no-window case, decided rather than discovered on device: a tap // The no-window case, decided rather than discovered on device: a tap

View file

@ -397,6 +397,13 @@ fn execute(shared: &Arc<Shared>, action: Action) {
"window-sheet-open", "window-sheet-open",
) )
} }
// The device's own verbs. No argument: unlike the sheet, the subject is
// the machine itself and there is only one of those.
Action::PowerMenu => (
"qs",
vec!["-c", "souveraine", "ipc", "call", "powerMenu", "open"],
"power-menu-open",
),
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

View file

@ -55,6 +55,7 @@ modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souverain
modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/qmldir modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/qmldir
modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/windowSheet/WindowSheet.qml modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/windowSheet/WindowSheet.qml
modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/windowSheet/SheetButton.qml modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/windowSheet/SheetButton.qml
modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windowSheet/PowerMenu.qml
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml services/ViewtopControl.qml souveraine/services/ViewtopControl.qml
modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml

View file

@ -0,0 +1,187 @@
// The device's own verbs, on a held power button.
//
// The counterpart to WindowSheet: that sheet is about a window, this is about
// the machine. Casey, 2026-08-05 "when we hold the power button ... we'd be
// given another context overlay", and "that whole power usb state probably
// belongs in the power switch options".
//
// ## Why the USB state lives here
//
// The USB-C port is not a preference, it is what the device currently *is*:
// host or device, sourcing power or sinking it, and what is on the other end.
// It changes the meaning of every peripheral attached, which makes it
// proprioception and puts it in sessiond beside panel, lock and idle. This
// surface is the view over that, not a second owner of it.
//
// ## What is honest here today
//
// The data role is real and readable `/sys/class/usb_role//role` reports
// `device`, and `pmi8998-charger` reports `online=1`, so "attached, drawing
// power" is a measured fact rather than a guess. **Switching** the role is not
// offered yet: the sysfs node is root-owned, so the flip has to be a sessiond
// verb, and a button that cannot do the thing it names is the exact failure
// this project keeps writing down. It is shown as state, and it says so.
//
// There is no `/sys/class/typec/` on this device the tcpm driver is not
// bound so the *power* role genuinely is not knowable here yet. The row
// reports what the charger says and claims nothing more.
//
// ## Why it is not gated on the lock
//
// Powering off is something you do to a locked phone. A menu that disappeared
// when locked would send the user back to the hardware button they are already
// holding. It shows device verbs and never session content, so disclosure is
// unaffected.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import "." as SheetLocal
Scope {
id: scope
property bool menuOpen: false
// Measured, not assumed. Re-read each time the menu opens rather than
// polled: the port's state only matters while someone is looking at it,
// and a timer here would be a background reader of a file nobody needs.
property string dataRole: "unknown"
property bool chargerOnline: false
function refresh() {
roleProc.running = true;
chargerProc.running = true;
}
function open() {
scope.refresh();
scope.menuOpen = true;
}
function dismiss() {
scope.menuOpen = false;
}
IpcHandler {
target: "powerMenu"
function open(): void {
scope.open();
}
function close(): void {
scope.dismiss();
}
function state(): string {
return scope.dataRole + " charger_online=" + scope.chargerOnline;
}
}
Process {
id: roleProc
command: ["cat", "/sys/class/usb_role/a600000.usb-role-switch/role"]
stdout: StdioCollector {
onStreamFinished: scope.dataRole = text.trim() || "unknown"
}
}
Process {
id: chargerProc
command: ["cat", "/sys/class/power_supply/pmi8998-charger/online"]
stdout: StdioCollector {
onStreamFinished: scope.chargerOnline = text.trim() === "1"
}
}
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: scope.menuOpen
WlrLayershell.namespace: "souveraine:powermenu"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: "#99000000"
MouseArea {
anchors.fill: parent
onClicked: scope.dismiss()
}
}
ColumnLayout {
anchors.centerIn: parent
spacing: 30
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 28
SheetLocal.SheetButton {
icon: "lock"
label: "Lock"
onTapped: {
scope.dismiss();
Session.lock();
}
}
SheetLocal.SheetButton {
icon: "restart_alt"
label: "Restart"
holdLabel: "hold to restart"
holdMs: 1000
// Hold, not tap. A restart reached by a single tap on a
// menu you opened by holding a button is one slip away
// from losing whatever was open.
onHeld: {
scope.dismiss();
Session.reboot();
}
}
SheetLocal.SheetButton {
icon: "power_settings_new"
label: "Power off"
holdLabel: "hold to power off"
holdMs: 1000
onHeld: {
scope.dismiss();
Session.poweroff();
}
}
}
// The port, as measured. Not a control yet see the header.
StyledText {
Layout.alignment: Qt.AlignHCenter
Layout.maximumWidth: win.width * 0.8
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: "#c8ffffff"
font.pixelSize: Appearance.font.pixelSize.small
text: "USB-C: " + scope.dataRole
+ (scope.chargerOnline ? " · charger attached" : " · no charger")
}
}
}
}
}

View file

@ -1,2 +1,3 @@
PowerMenu 1.0 PowerMenu.qml
SheetButton 1.0 SheetButton.qml SheetButton 1.0 SheetButton.qml
WindowSheet 1.0 WindowSheet.qml WindowSheet 1.0 WindowSheet.qml

View file

@ -85,6 +85,9 @@ Scope {
// the hand can close a window under viewtop, and the dial's own "Kill // the hand can close a window under viewtop, and the dial's own "Kill
// window" is a dead `hyprctl dispatch`. // window" is a dead `hyprctl dispatch`.
PanelLoader { component: WindowSheet {} } PanelLoader { component: WindowSheet {} }
// The device's verbs, on a held power button. Ungated and not lock-gated:
// powering off is something you do to a locked phone.
PanelLoader { component: PowerMenu {} }
// Text-selection action menu (TASK-18). Gated on the opt-in config: the // 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 // service behind it watches every selection on the device, so it must not
// load by default. See Config.options.selection. // load by default. See Config.options.selection.