hold power for a device menu; usb-c state shown, not faked
This commit is contained in:
parent
bf8bc013ef
commit
f7eba9f658
6 changed files with 248 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
187
surfaces/quickshell/modules/souveraine/windowSheet/PowerMenu.qml
Normal file
187
surfaces/quickshell/modules/souveraine/windowSheet/PowerMenu.qml
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue