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
|
||||
/// difference — the sheet is *about a window*, and the tap now knows which.
|
||||
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
|
||||
/// the session is not locked yet.
|
||||
///
|
||||
|
|
@ -1665,15 +1677,33 @@ impl DeviceStateMachine {
|
|||
gesture: ButtonGesture,
|
||||
now: Instant,
|
||||
) -> Vec<Action> {
|
||||
let bound = button == Button::Power
|
||||
&& matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
|
||||
self.record_decision(
|
||||
"button-gesture",
|
||||
serde_json::json!({
|
||||
"button": button.as_str(),
|
||||
"gesture": gesture.as_str(),
|
||||
"bound": button == Button::Power && gesture == ButtonGesture::Tap,
|
||||
"bound": bound,
|
||||
}),
|
||||
"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 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
|
@ -3237,6 +3267,24 @@ mod tests {
|
|||
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]
|
||||
fn a_three_finger_tap_on_the_wallpaper_raises_nothing() {
|
||||
// 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",
|
||||
)
|
||||
}
|
||||
// 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 => {
|
||||
// The invariant, enforced where it cannot be reasoned around: the
|
||||
// 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/windowSheet/WindowSheet.qml souveraine/modules/souveraine/windowSheet/WindowSheet.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
|
||||
services/ViewtopControl.qml souveraine/services/ViewtopControl.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
|
||||
WindowSheet 1.0 WindowSheet.qml
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ Scope {
|
|||
// the hand can close a window under viewtop, and the dial's own "Kill
|
||||
// window" is a dead `hyprctl dispatch`.
|
||||
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
|
||||
// service behind it watches every selection on the device, so it must not
|
||||
// load by default. See Config.options.selection.
|
||||
|
|
|
|||
Loading…
Reference in a new issue