From bf8bc013efdfa80ece914577abf0965f42fb5bb0 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Wed, 5 Aug 2026 14:12:22 -0400 Subject: [PATCH] three-finger tap opens a window sheet, not the overview --- src/sessiond/device_state.rs | 64 +++-- src/sessiond/protocol.rs | 17 +- src/sessiond/server.rs | 41 +++- surfaces/quickshell/SettingsWindow.qml | 5 + surfaces/quickshell/deploy.sh | 8 + .../scripts/ai/gemini-categorize-wallpaper.sh | 65 +++++ surfaces/quickshell/modules/common/Config.qml | 19 ++ .../common/widgets/FloatingActionButton.qml | 78 ++++++ .../background/widgets/clock/CookieClock.qml | 222 ++++++++++++++++++ .../modules/ii/overview/AppGrid.qml | 201 ++++++++++++++++ .../modules/ii/overview/Overview.qml | 186 +++++++++++++-- .../quickshell/modules/ii/overview/qmldir | 1 + .../modules/settings/OverviewConfig.qml | 80 +++++++ surfaces/quickshell/modules/settings/qmldir | 1 + .../souveraine/windowSheet/SheetButton.qml | 143 +++++++++++ .../souveraine/windowSheet/WindowSheet.qml | 207 ++++++++++++++++ .../modules/souveraine/windowSheet/qmldir | 2 + .../panelFamilies/SouveraineFamily.qml | 5 + .../quickshell/services/ViewtopControl.qml | 174 ++++++++++++++ surfaces/quickshell/services/qmldir | 1 + surfaces/quickshell/settings.qml | 5 + 21 files changed, 1476 insertions(+), 49 deletions(-) create mode 100755 surfaces/quickshell/ii-phone/scripts/ai/gemini-categorize-wallpaper.sh create mode 100644 surfaces/quickshell/modules/common/widgets/FloatingActionButton.qml create mode 100644 surfaces/quickshell/modules/ii/background/widgets/clock/CookieClock.qml create mode 100644 surfaces/quickshell/modules/ii/overview/AppGrid.qml create mode 100644 surfaces/quickshell/modules/settings/OverviewConfig.qml create mode 100644 surfaces/quickshell/modules/souveraine/windowSheet/SheetButton.qml create mode 100644 surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml create mode 100644 surfaces/quickshell/modules/souveraine/windowSheet/qmldir create mode 100644 surfaces/quickshell/services/ViewtopControl.qml diff --git a/src/sessiond/device_state.rs b/src/sessiond/device_state.rs index a89fe8b..ed2970c 100644 --- a/src/sessiond/device_state.rs +++ b/src/sessiond/device_state.rs @@ -408,7 +408,7 @@ pub enum Action { /// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the /// panel, only sessiond moves the lock". Unblank, - /// Raise the window overview. + /// Raise the window action sheet for one window. /// /// A touch gesture bound the way every other control is: viewtop reports /// what the fingers did, the machine decides what it means, and the @@ -421,7 +421,13 @@ pub enum Action { /// The compositor has to do the *recognition* because contacts only exist /// there, which is why this arrives already named rather than as raw /// touches. What it must not do is decide what a name means. - Overview, + /// + /// This replaced `Overview`, which the same tap used to raise. Two gestures + /// reaching one surface is the one-decider problem in miniature: the rail's + /// swipe already opens the overview, so the tap spent its whole existence + /// 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 }, /// Request the session lock, because something wants the panel dark and /// the session is not locked yet. /// @@ -1714,13 +1720,27 @@ impl DeviceStateMachine { /// chose is worse than one that fires nothing, and the binding table this /// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and /// agent-writable, so a triple-tap can be re-pointed without a rebuild. - pub fn touch_gesture(&mut self, fingers: u8, gesture: TouchGesture) -> Vec { - let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap); + pub fn touch_gesture( + &mut self, + fingers: u8, + gesture: TouchGesture, + target: Option, + ) -> Vec { + // A three-finger tap raises the window action sheet for the window it + // landed on. It used to raise the overview, and that was wrong twice + // over: the rail's swipe already reaches the same surface — a gesture + // that duplicates another gesture is a defect by construction — and + // the tap arrived with no subject at all, because the compositor + // dropped the centroid, so the overview was the only thing it *could* + // name. Now that the target travels with the gesture, the tap can be + // about the window under the fingers, which is what it was always for. + let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap) && target.is_some(); self.record_decision( "touch-gesture", serde_json::json!({ "fingers": fingers, "gesture": gesture.as_str(), + "target": target, "bound": bound, }), "touch gesture recognised", @@ -1728,14 +1748,20 @@ impl DeviceStateMachine { if !bound { return Vec::new(); } - // Not while the screen is dark: the overview is content, and putting + // Not while the screen is dark: the sheet is content, and putting // content on an unauthenticated glass is what §4's disclosure rules // exist to prevent. A tap on a dark panel is a wake, and that is the // power button's business, not this one's. if !self.panel_on { return Vec::new(); } - vec![Action::Overview] + // `target` is Some — `bound` required it. A tap on the wallpaper falls + // out above with `bound: false` in the trail rather than raising an + // empty sheet, which is the "why did a blank sheet appear" bug the + // no-window case exists to avoid. + vec![Action::WindowSheet { + target: target.unwrap_or_default(), + }] } pub fn tick_at(&mut self, now: Instant) -> Vec { @@ -3205,10 +3231,19 @@ mod tests { } #[test] - fn three_fingers_raise_the_overview() { + fn three_fingers_raise_the_sheet_for_the_window_they_landed_on() { let (mut sm, _t0) = unlocked_and_lit(); - let actions = sm.touch_gesture(3, TouchGesture::Tap); - assert_eq!(actions, vec![Action::Overview]); + let actions = sm.touch_gesture(3, TouchGesture::Tap, Some(7)); + assert_eq!(actions, vec![Action::WindowSheet { target: 7 }]); + } + + #[test] + fn a_three_finger_tap_on_the_wallpaper_raises_nothing() { + // The no-window case, decided rather than discovered on device: a tap + // with no subject is inert. The alternative is a sheet with nothing to + // act on, which is the "why did a blank sheet appear" bug. + let (mut sm, _t0) = unlocked_and_lit(); + assert!(sm.touch_gesture(3, TouchGesture::Tap, None).is_empty()); } #[test] @@ -3223,24 +3258,25 @@ mod tests { TouchGesture::SwipeLeft, TouchGesture::SwipeRight, ] { - assert!(sm.touch_gesture(3, g).is_empty(), "{g:?}"); + assert!(sm.touch_gesture(3, g, Some(1)).is_empty(), "{g:?}"); } for fingers in [1u8, 2, 4, 5] { assert!( - sm.touch_gesture(fingers, TouchGesture::Tap).is_empty(), + sm.touch_gesture(fingers, TouchGesture::Tap, Some(1)) + .is_empty(), "{fingers} fingers" ); } } #[test] - fn the_overview_does_not_open_on_a_dark_panel() { - // The overview is content, and content on an unauthenticated glass is + fn the_sheet_does_not_open_on_a_dark_panel() { + // The sheet is content, and content on an unauthenticated glass is // what the disclosure rules exist to prevent. A tap on a dark panel is // a wake, and that is the power button's business. let (mut sm, _t0) = unlocked_and_lit(); sm.set_panel(false); - assert!(sm.touch_gesture(3, TouchGesture::Tap).is_empty()); + assert!(sm.touch_gesture(3, TouchGesture::Tap, Some(1)).is_empty()); } #[test] diff --git a/src/sessiond/protocol.rs b/src/sessiond/protocol.rs index 6592072..0d4493c 100644 --- a/src/sessiond/protocol.rs +++ b/src/sessiond/protocol.rs @@ -186,7 +186,7 @@ pub const VERBS: &[VerbDoc] = &[ mutates: true, summary: "a recognised touch gesture; the machine decides what it means", refuses: &[RefusalCode::InvalidArgument], - example: r#"{"op":"gesture","fingers":3,"gesture":"tap"}"#, + example: r#"{"op":"gesture","fingers":3,"gesture":"tap","target":1}"#, }, VerbDoc { op: "panel", @@ -315,7 +315,20 @@ pub enum Request { /// recognise them. The line §12 draws still holds — the compositor names /// what the fingers did and the machine decides what it means. A compositor /// that both recognised and acted would be the eighth blind actor. - Gesture { fingers: u8, gesture: TouchGesture }, + /// + /// `target` is the window the gesture landed on, named by the compositor + /// through the same hit test a finger goes through. It travels with the + /// gesture because that mapping exists nowhere else — it accounts for the + /// zone strip and any pose — and a machine re-deriving it from a centroid + /// would eventually disagree with what the hand actually hit. Absent means + /// the gesture landed on the wallpaper, which is a real answer and a + /// different one from "no window exists". + Gesture { + fingers: u8, + gesture: TouchGesture, + #[serde(default)] + target: Option, + }, /// The DPMS executor reports the panel's real power state. The machine /// keeps the panel as a field, not a state: "locked with the screen off" /// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark diff --git a/src/sessiond/server.rs b/src/sessiond/server.rs index 379618d..b7ab76c 100644 --- a/src/sessiond/server.rs +++ b/src/sessiond/server.rs @@ -341,6 +341,11 @@ fn execute(shared: &Arc, action: Action) { return; } + // The sheet's target, rendered once and outliving the match so the table's + // `&str` arguments can borrow it. Every other row's arguments are static; + // this is the first action that carries a value into its own command line. + let sheet_target: String; + let (program, args, label): (&str, Vec<&str>, &str) = match action { // sessiond guarantees this runs once per dim, which is what the old // hypridle `-s`/`-r` listener could not: a second save while already @@ -372,14 +377,26 @@ fn execute(shared: &Arc, action: Action) { vec!["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"], "volume-down", ), - // The shell owns the overview; the machine owns what opens it. Same - // shape as every other row here — a named tool with named arguments, - // one place, in the trail. - Action::Overview => ( - "qs", - vec!["-c", "souveraine", "ipc", "call", "overview", "toggle"], - "overview-toggle", - ), + // The shell owns the sheet; the machine owns what opens it, and for + // which window. Same shape as every other row — a named tool with + // named arguments, one place, in the trail — except that the window is + // an argument, because a sheet with no subject is the bug this replaced. + Action::WindowSheet { target } => { + sheet_target = target.to_string(); + ( + "qs", + vec![ + "-c", + "souveraine", + "ipc", + "call", + "windowSheet", + "open", + &sheet_target, + ], + "window-sheet-open", + ) + } Action::Blank => { // The invariant, enforced where it cannot be reasoned around: the // panel does not go dark unless logind says this session is @@ -1256,10 +1273,14 @@ fn handle_request( } serde_json::json!({ "ok": true }) } - Request::Gesture { fingers, gesture } => { + Request::Gesture { + fingers, + gesture, + target, + } => { let actions = { let mut d = shared.lock(); - d.device_state.touch_gesture(fingers, gesture) + d.device_state.touch_gesture(fingers, gesture, target) }; let bound = !actions.is_empty(); for action in actions { diff --git a/surfaces/quickshell/SettingsWindow.qml b/surfaces/quickshell/SettingsWindow.qml index fba6f86..215d604 100644 --- a/surfaces/quickshell/SettingsWindow.qml +++ b/surfaces/quickshell/SettingsWindow.qml @@ -74,6 +74,11 @@ ApplicationWindow { icon: "wallpaper", component: "modules/settings/WallpaperConfig.qml" }, + { + name: Translation.tr("Home screen"), + icon: "apps", + component: "modules/settings/OverviewConfig.qml" + }, { name: Translation.tr("Dock"), icon: "dock_to_bottom", diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index 2a62613..7dae3cd 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -53,6 +53,10 @@ modules/souveraine/selection/SelectionActions.qml souveraine/modules/souveraine/ 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 +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/qmldir souveraine/modules/souveraine/windowSheet/qmldir +services/ViewtopControl.qml souveraine/services/ViewtopControl.qml modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml modules/souveraine/subconscious/qmldir souveraine/modules/souveraine/subconscious/qmldir @@ -86,6 +90,7 @@ modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml modules/common/widgets/FullscreenPolkitWindow.qml souveraine/modules/common/widgets/FullscreenPolkitWindow.qml +modules/common/widgets/FloatingActionButton.qml souveraine/modules/common/widgets/FloatingActionButton.qml modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml modules/settings/NetworkConfig.qml souveraine/modules/settings/NetworkConfig.qml @@ -93,6 +98,7 @@ modules/settings/DisplayConfig.qml souveraine/modules/settings/DisplayConfig.qm modules/settings/SoundConfig.qml souveraine/modules/settings/SoundConfig.qml modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml modules/settings/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.qml +modules/settings/OverviewConfig.qml souveraine/modules/settings/OverviewConfig.qml modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.qml @@ -109,6 +115,8 @@ modules/ii/dock/DockStack.qml souveraine/modules/ii/dock/DockStack.qml modules/ii/appInventory/AppInventory.qml souveraine/modules/ii/appInventory/AppInventory.qml modules/ii/appInventory/AppInventoryScope.qml souveraine/modules/ii/appInventory/AppInventoryScope.qml modules/ii/overview/Overview.qml souveraine/modules/ii/overview/Overview.qml +modules/ii/overview/AppGrid.qml souveraine/modules/ii/overview/AppGrid.qml +modules/ii/background/widgets/clock/CookieClock.qml souveraine/modules/ii/background/widgets/clock/CookieClock.qml modules/ii/screenCorners/ScreenCorners.qml souveraine/modules/ii/screenCorners/ScreenCorners.qml modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml modules/common/Persistent.qml souveraine/modules/common/Persistent.qml diff --git a/surfaces/quickshell/ii-phone/scripts/ai/gemini-categorize-wallpaper.sh b/surfaces/quickshell/ii-phone/scripts/ai/gemini-categorize-wallpaper.sh new file mode 100755 index 0000000..856a438 --- /dev/null +++ b/surfaces/quickshell/ii-phone/scripts/ai/gemini-categorize-wallpaper.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +if [[ -z "$1" ]]; then + echo "Usage: $0 [model] [prompt]" + echo "Tip: set GEMINI_WALLPAPER_MODEL and/or GEMINI_WALLPAPER_PROMPT to provide defaults." + exit 1 +fi + +# Variables +SOURCE_IMG_PATH="$1" +MODEL="${2:-${GEMINI_WALLPAPER_MODEL:-gemini-3.6-flash}}" # Souveraine overlay: gemini-2.5-flash 404s for new free-tier keys (2026-08) +WALLPAPER_NAME="$(basename "$SOURCE_IMG_PATH")" +PROMPT="${3:-${GEMINI_WALLPAPER_PROMPT:-Categorize the wallpaper. Its file name is $WALLPAPER_NAME}}" +RESIZED_IMG_PATH="/tmp/quickshell/ai/wallpaper.jpg" + +# Resize image for speed +mkdir -p "$(dirname "$RESIZED_IMG_PATH")" +magick "$SOURCE_IMG_PATH" -resize 200x -quality 50 "$RESIZED_IMG_PATH" + +# Get API key +API_KEY=$(secret-tool lookup 'application' 'illogical-impulse' | jq -r '.apiKeys.gemini') + +# Encode image to base64 +if [[ "$(base64 --version 2>&1)" = *"FreeBSD"* ]]; then + B64FLAGS="--input" +else + B64FLAGS="-w0" +fi +B64DATA="$(base64 $B64FLAGS $RESIZED_IMG_PATH)" +# echo $B64DATA + +# Prepare request data +payload='{ + "contents": [{ + "parts":[ + { + "inline_data": { + "mime_type":"image/jpeg", + "data": "'"$B64DATA"'" + } + }, + {"text": "'"$PROMPT"'"} + ] + }], + "generationConfig": { + "responseMimeType": "text/x.enum", + "responseSchema": { + "type": "string", + "enum": [ "abstract", "anime", "city", "minimalist", "landscape", "plants", "person", "space" ] + }, + "temperature": 0 + } +}' +# echo "$payload" | jq + +# Make the request +response=$(curl "https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent" \ +-H "x-goog-api-key: $API_KEY" \ +-H 'Content-Type: application/json' \ +-X POST \ +-d "$payload" 2> /dev/null) +# echo "$response" | jq + +# Write the result +echo "$response" | jq -r '.candidates[0].content.parts[0].text' diff --git a/surfaces/quickshell/modules/common/Config.qml b/surfaces/quickshell/modules/common/Config.qml index 07fec55..2faaf6a 100644 --- a/surfaces/quickshell/modules/common/Config.qml +++ b/surfaces/quickshell/modules/common/Config.qml @@ -179,7 +179,17 @@ Singleton { property string style: "cookie" // Options: "cookie", "digital" property string styleLocked: "cookie" // Options: "cookie", "digital" property JsonObject cookie: JsonObject { + // aiStyling gates the categorize step in + // switchwall.sh (it must be true for wallpapers to + // be AI-categorized on apply). aiPreset is the + // SEPARATE gate for CookieClock applying a + // category preset over the user's configured clock + // style — split 2026-08-05 after turning on + // aiStyling rewrote the phone's clock + // (applyStyle writes these keys). Default off: the + // clock keeps whatever the user set. property bool aiStyling: false + property bool aiPreset: false property int sides: 14 property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none" property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide" @@ -612,6 +622,15 @@ Singleton { property bool orderRightLeft: false property bool orderBottomUp: false property bool centerIcons: true + // The app drawer's own grid (TASK-14). Kept nested instead of + // overloading rows/columns above — those are the desktop + // overview's workspace-grid knobs, and the drawer is a + // different density. The settings app writes these. + property JsonObject appGrid: JsonObject { + property int columns: 4 + property int rows: 5 + property int iconSize: 44 + } } property JsonObject regionSelector: JsonObject { diff --git a/surfaces/quickshell/modules/common/widgets/FloatingActionButton.qml b/surfaces/quickshell/modules/common/widgets/FloatingActionButton.qml new file mode 100644 index 0000000..a1bdcbd --- /dev/null +++ b/surfaces/quickshell/modules/common/widgets/FloatingActionButton.qml @@ -0,0 +1,78 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +/** + * Material 3 FAB. + * + * Souveraine override of ii's widget: the label inside the collapsed Revealer + * centers on the button instead of the Revealer (see the comment at + * buttonText), breaking the implicitHeight binding loop upstream logs on every + * startup. Everything else is verbatim ii — diff against ii-base before + * re-applying if ii updates. + */ +RippleButton { + id: root + property string iconText: "add" + property bool expanded: false + property real baseSize: 56 + property real elementSpacing: 5 + implicitWidth: expanded ? (Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize)) : baseSize + implicitHeight: baseSize + buttonRadius: baseSize / 14 * 4 + colBackground: Appearance.colors.colPrimaryContainer + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive + property color colOnBackground: Appearance.colors.colOnPrimaryContainer + contentItem: Row { + id: contentRowLayout + property real horizontalMargins: (root.baseSize - icon.width) / 2 + anchors { + verticalCenter: parent?.verticalCenter + left: parent?.left + leftMargin: contentRowLayout.horizontalMargins + } + spacing: 0 + + MaterialSymbol { + id: icon + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 26 + color: root.colOnBackground + text: root.iconText + } + Loader { + anchors.verticalCenter: parent.verticalCenter + visible: root.buttonText?.length > 0 + active: true + sourceComponent: Revealer { + visible: root.expanded || implicitWidth > 0 + reveal: root.expanded + implicitWidth: reveal ? (buttonText.implicitWidth + root.elementSpacing + contentRowLayout.horizontalMargins) : 0 + StyledText { + id: buttonText + anchors { + left: parent.left + leftMargin: root.elementSpacing + // Center on the BUTTON, not the Revealer (2026-08-05). + // Centering on the Revealer bound the text's y to the + // Revealer's height, whose implicitHeight is + // childrenRect.height, which depends on the text's + // y — the implicitHeight binding loop logged on every + // startup (FloatingActionButton.qml[45:30]). The + // Revealer and the button share a center anyway, so + // this is visually identical and acyclic. + verticalCenter: root.verticalCenter + } + text: root.buttonText + color: Appearance.colors.colOnPrimaryContainer + font.pixelSize: 14 + font.weight: 450 + } + } + } + } +} diff --git a/surfaces/quickshell/modules/ii/background/widgets/clock/CookieClock.qml b/surfaces/quickshell/modules/ii/background/widgets/clock/CookieClock.qml new file mode 100644 index 0000000..df253f9 --- /dev/null +++ b/surfaces/quickshell/modules/ii/background/widgets/clock/CookieClock.qml @@ -0,0 +1,222 @@ +pragma ComponentBehavior: Bound + +// Souveraine fork of ii's CookieClock. One change: the category-preset gate +// reads cookie.aiPreset instead of cookie.aiStyling (see the comment at +// setClockPreset). Diff against ii-base before re-applying if ii updates. + +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io + +import qs.modules.ii.background.widgets.clock.dateIndicator +import qs.modules.ii.background.widgets.clock.minuteMarks + +Item { + id: root + + readonly property string clockStyle: Config.options.background.widgets.clock.style + + property real implicitSize: 230 + + property color colShadow: Appearance.colors.colShadow + property color colBackground: Appearance.colors.colPrimaryContainer + property color colOnBackground: ColorUtils.mix(Appearance.colors.colSecondary, Appearance.colors.colPrimaryContainer, 0.15) + property color colBackgroundInfo: ColorUtils.mix(Appearance.colors.colPrimary, Appearance.colors.colPrimaryContainer, 0.55) + property color colHourHand: Appearance.colors.colPrimary + property color colMinuteHand: Appearance.colors.colTertiary + property color colSecondHand: Appearance.colors.colPrimary + + readonly property list clockNumbers: DateTime.time.split(/[: ]/) + readonly property int clockHour: parseInt(clockNumbers[0]) % 12 + readonly property int clockMinute: DateTime.clock.minutes + readonly property int clockSecond: DateTime.clock.seconds + + implicitWidth: implicitSize + implicitHeight: implicitSize + + function applyStyle(sides, dialStyle, hourHandStyle, minuteHandStyle, secondHandStyle, dateStyle) { + Config.options.background.widgets.clock.cookie.sides = sides + Config.options.background.widgets.clock.cookie.dialNumberStyle = dialStyle + Config.options.background.widgets.clock.cookie.hourHandStyle = hourHandStyle + Config.options.background.widgets.clock.cookie.minuteHandStyle = minuteHandStyle + Config.options.background.widgets.clock.cookie.secondHandStyle = secondHandStyle + Config.options.background.widgets.clock.cookie.dateStyle = dateStyle + } + + function setClockPreset(category) { + // Souveraine fork (2026-08-05): gate is cookie.aiPreset, not + // cookie.aiStyling. Upstream runs both the wallpaper-categorizer + // (switchwall.sh) and this preset override off the one flag, so + // turning categorization on rewrote users' configured clock styles + // via applyStyle. aiStyling stays true for the pipeline; this + // surface only moves when aiPreset is explicitly on. Verbatim + // upstream otherwise. + if (!Config.options.background.widgets.clock.cookie.aiPreset) return; + if (category === "") return; + print("[Cookie clock] Setting clock preset for category: " + category) + // "abstract", "anime", "city", "minimalist", "landscape", "plants", "person", "space" + if (category == "abstract") { + applyStyle(9, "none", "fill", "medium", "dot", "bubble") + } else if (category == "anime") { + applyStyle(7, "none", "fill", "bold", "dot", "bubble") + } else if (category == "city" || category == "space") { + applyStyle(23, "full", "hollow", "thin", "classic", "bubble") + } else if (category == "minimalist") { + applyStyle(6, "none", "fill", "bold", "dot", "hide") + } else if (category == "landscape") { + applyStyle(14, "full", "hollow", "medium", "classic", "bubble") + } else if (category == "plants") { + applyStyle(9, "dots", "fill", "bold", "dot", "border") + } else if (category == "person") { + applyStyle(14, "full", "classic", "classic", "classic", "rect") + } + } + + FileView { + id: categoryFileView + path: Config.ready ? Directories.generatedWallpaperCategoryPath : "" + watchChanges: true + onFileChanged: reload() + onLoaded: { + root.setClockPreset(categoryFileView.text().trim()) + } + } + + property bool useSineCookie: Config.options.background.widgets.clock.cookie.useSineCookie + StyledDropShadow { + target: root.useSineCookie ? sineCookieLoader : roundedPolygonCookieLoader + + RotationAnimation on rotation { + running: Config.options.background.widgets.clock.cookie.constantlyRotate + duration: 30000 + easing.type: Easing.Linear + loops: Animation.Infinite + from: 360 + to: 0 + } + } + Loader { + id: sineCookieLoader + z: 0 + visible: false // The DropShadow already draws it + active: root.useSineCookie + sourceComponent: SineCookie { + implicitSize: root.implicitSize + sides: Config.options.background.widgets.clock.cookie.sides + color: root.colBackground + } + } + Loader { + id: roundedPolygonCookieLoader + z: 0 + visible: false // The DropShadow already draws it + active: !root.useSineCookie + sourceComponent: MaterialCookie { + implicitSize: root.implicitSize + sides: Config.options.background.widgets.clock.cookie.sides + color: root.colBackground + } + } + + // Hour/minutes numbers/dots/lines + MinuteMarks { + anchors.fill: parent + color: root.colOnBackground + } + + // Stupid extra hour marks in the middle + FadeLoader { + id: hourMarksLoader + anchors.centerIn: parent + shown: Config.options.background.widgets.clock.cookie.hourMarks + sourceComponent: HourMarks { + implicitSize: 135 * (1.75 - 0.75 * hourMarksLoader.opacity) + color: root.colOnBackground + colOnBackground: ColorUtils.mix(root.colBackgroundInfo, root.colOnBackground, 0.5) + } + } + + // Number column in the middle + FadeLoader { + id: timeColumnLoader + anchors.centerIn: parent + shown: Config.options.background.widgets.clock.cookie.timeIndicators + scale: 1.4 - 0.4 * timeColumnLoader.shown + Behavior on scale { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + + sourceComponent: TimeColumn { + color: root.colBackgroundInfo + } + } + + // Minute hand + FadeLoader { + anchors.fill: parent + z: 1 + shown: Config.options.background.widgets.clock.cookie.minuteHandStyle !== "hide" + sourceComponent: MinuteHand { + anchors.fill: parent + clockMinute: root.clockMinute + style: Config.options.background.widgets.clock.cookie.minuteHandStyle + color: root.colMinuteHand + } + } + + // Hour hand + FadeLoader { + anchors.fill: parent + z: item?.style === "hollow" ? 0 : 2 + shown: Config.options.background.widgets.clock.cookie.hourHandStyle !== "hide" + sourceComponent: HourHand { + clockHour: root.clockHour + clockMinute: root.clockMinute + style: Config.options.background.widgets.clock.cookie.hourHandStyle + color: root.colHourHand + } + } + + // Second hand + FadeLoader { + id: secondHandLoader + z: (Config.options.background.widgets.clock.cookie.secondHandStyle === "line") ? 2 : 3 + shown: Config.options.time.secondPrecision && Config.options.background.widgets.clock.cookie.secondHandStyle !== "hide" + anchors.fill: parent + sourceComponent: SecondHand { + id: secondHand + clockSecond: root.clockSecond + style: Config.options.background.widgets.clock.cookie.secondHandStyle + color: root.colSecondHand + } + } + + // Center dot + FadeLoader { + z: 4 + anchors.centerIn: parent + shown: Config.options.background.widgets.clock.cookie.minuteHandStyle !== "bold" + sourceComponent: Rectangle { + color: Config.options.background.widgets.clock.cookie.minuteHandStyle === "medium" ? root.colBackground : root.colMinuteHand + implicitWidth: 6 + implicitHeight: implicitWidth + radius: width / 2 + } + } + + // Date + FadeLoader { + anchors.fill: parent + shown: Config.options.background.widgets.clock.cookie.dateStyle !== "hide" + + sourceComponent: DateIndicator { + color: root.colBackgroundInfo + style: Config.options.background.widgets.clock.cookie.dateStyle + } + } +} diff --git a/surfaces/quickshell/modules/ii/overview/AppGrid.qml b/surfaces/quickshell/modules/ii/overview/AppGrid.qml new file mode 100644 index 0000000..3892dc4 --- /dev/null +++ b/surfaces/quickshell/modules/ii/overview/AppGrid.qml @@ -0,0 +1,201 @@ +// The phone app drawer: every desktop entry as a paged icon grid. +// +// TASK-14. The overview pane is stock ii's *desktop* overview — workspace +// thumbnails + search. On a 5.5" phone the workspace grid is dead weight, so +// the body becomes what a phone Home key opens: an app grid. This lives in its +// own file so Overview.qml only swaps the body widget and ii updates stay +// mergeable (the header comment in Overview.qml, "diff against upstream before +// re-applying"; the original stock body is `ii-base/modules/ii/overview/ +// OverviewWidget.qml`). +// +// Deliberately NOT the workspace grid's sibling: the reference shell's +// overview is built on windows, and running windows have their own surface — +// WindowOverview, raised by mission control (the pill's second-stage swipe). An +// app drawer is for *finding* something, not for *switching* to what is +// running, so this grid has no notion of workspaces or of what is currently +// focused. It is the launcher half of the split; WindowOverview is the task +// half. Do not merge them: that conflation is what landed the running-cards on +// the Home page (the `overviewOpen || missionControlOpen` body in Overview.qml +// that this file replaces). +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import Quickshell +import Quickshell.Widgets + +Item { + id: root + + // --- Layout ---------------------------------------------------------- + // 4x5 on the 540x1080 portrait. The reference shell's layout search + // collapses to a single column of full-width cards in portrait — that is + // the *task* surface. An app drawer is density, not drama, so this is a + // dense grid: 4 columns keeps a thumb travel across a page, 5 rows fills + // the 0.78-height body without needing the whole 1080. Columns, rows and + // icon size are user-settable in the settings app via + // Config.options.overview.appGrid (see Config.qml); the fallbacks match + // the phone's measured defaults. `property JsonObject` reads resolve + // against the active config file at load, not against Config.qml's base + // values — so these are fallbacks, not overrides. + readonly property int columns: Math.max(1, Math.min(10, + Config?.options?.overview?.appGrid?.columns ?? 4)) + readonly property int rows: Math.max(1, Math.min(10, + Config?.options?.overview?.appGrid?.rows ?? 5)) + readonly property int iconSize: Math.max(24, Math.min(96, + Config?.options?.overview?.appGrid?.iconSize ?? 44)) + readonly property int perPage: columns * rows + readonly property real pageMargin: 14 + readonly property real cellSpacing: 6 + + // --- Data ------------------------------------------------------------ + // AppSearch.list is the canonical deduped DesktopEntry list (the same one + // the search backend reads), sorted here alphabetically. Paging is a + // property, not a ListModel, so a desktop-entry change recomputes cleanly. + readonly property var apps: { + const all = AppSearch.list.slice() + .filter(app => app?.name) + .sort((a, b) => (a.name || "").localeCompare(b.name || "")); + return all; + } + readonly property var pages: { + const result = []; + for (let i = 0; i < root.apps.length; i += root.perPage) + result.push(root.apps.slice(i, i + root.perPage)); + return result; + } + readonly property int pageCount: root.pages.length + + // How far open, 0..1 — the same single-progress idiom WindowOverview uses + // so this surface reads as arriving, not appearing. The overview's own + // gate; mission control drives its own surface. + property real progress: GlobalStates.overviewOpen ? 1 : 0 + Behavior on progress { + NumberAnimation { + duration: 260 + easing.type: Easing.OutCubic + } + } + transform: Translate { y: (1 - root.progress) * 24 } + opacity: root.progress + + // Nothing installed worth showing. Kept distinct from a broken grid on + // purpose — the same silence-was-the-bug discipline WindowOverview's + // "No open windows" label exists for. + StyledText { + anchors.centerIn: parent + visible: root.apps.length === 0 + text: qsTr("No apps") + opacity: 0.6 + } + + ListView { + id: list + anchors.fill: parent + // Room for the page dots below the grid. + anchors.bottomMargin: 22 + // Paged horizontally, per TASK-14 ("paged"), matching the task + // surface's axis so the thumb does one learned motion for both. + orientation: ListView.Horizontal + snapMode: ListView.SnapOneItem + highlightRangeMode: ListView.StrictlyEnforceRange + preferredHighlightBegin: 0 + preferredHighlightEnd: width + spacing: 0 + clip: true + visible: root.apps.length > 0 + model: root.pages + + // The page the strip is on, live (not ListView.currentIndex, which + // only follows keyboard/programmatic selection); feeds the dots. + readonly property int pageIndex: Math.max(0, Math.min(root.pageCount - 1, + Math.round(list.contentX / list.width))) + + delegate: Item { + id: page + required property var modelData + required property int index + width: list.width + height: list.height + + readonly property real tileW: (width - root.pageMargin * 2 + - root.cellSpacing * (root.columns - 1)) / root.columns + readonly property real tileH: (height - root.cellSpacing * (root.rows - 1)) / root.rows + + Grid { + anchors.fill: parent + anchors.margins: root.pageMargin + columns: root.columns + rows: root.rows + columnSpacing: root.cellSpacing + rowSpacing: root.cellSpacing + + Repeater { + model: page.modelData + delegate: RippleButton { + required property var modelData + implicitWidth: page.tileW + implicitHeight: page.tileH + buttonRadius: Appearance?.rounding?.normal ?? 12 + // Launch closes the drawer; the tapped app takes the + // screen. Same close-before-launch order SearchItem + // uses — the entry must not execute into an overview + // still covering the monitor. + onClicked: { + GlobalStates.overviewOpen = false; + modelData.execute(); + } + contentItem: Column { + anchors.fill: parent + anchors.topMargin: 12 + spacing: 6 + + IconImage { + anchors.horizontalCenter: parent.horizontalCenter + source: Quickshell.iconPath(modelData.icon, "image-missing") + implicitWidth: root.iconSize + implicitHeight: root.iconSize + } + + StyledText { + width: parent.width + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignTop + elide: Text.ElideRight + maximumLineCount: 1 + text: modelData.name + font.pixelSize: Appearance?.font?.pixelSize?.small ?? 12 + color: Appearance?.colors?.colOnLayer0 ?? "#fff" + opacity: 0.85 + } + } + } + } + } + } + } + + // Page dots. One per page, the current one in the accent; only shown when + // there is more than one page (a single dot that cannot move is noise). + Row { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 4 + visible: root.pageCount > 1 + spacing: 6 + Repeater { + model: root.pageCount + delegate: Rectangle { + required property int index + width: 7 + height: 7 + radius: width / 2 + color: index === list.pageIndex + ? (Appearance?.colors?.colPrimary ?? "#a0c8ff") + : "#66ffffff" + Behavior on color { ColorAnimation { duration: 150 } } + } + } + } +} \ No newline at end of file diff --git a/surfaces/quickshell/modules/ii/overview/Overview.qml b/surfaces/quickshell/modules/ii/overview/Overview.qml index 5049197..e58bedf 100644 --- a/surfaces/quickshell/modules/ii/overview/Overview.qml +++ b/surfaces/quickshell/modules/ii/overview/Overview.qml @@ -1,8 +1,14 @@ -// Pixel3Arch patch to ii's stock Overview.qml (2026-07-07): opening the -// search/overview automatically brings up the on-screen keyboard -// (GlobalStates.oskOpen), and closing it closes the keyboard too. +// Pixel3Arch patch to ii's stock Overview.qml. History: // -// Also: while the OSK is open, this panel does NOT register with +// 2026-07-07 — opened the search/overview with the on-screen keyboard +// (GlobalStates.oskOpen) and closed it with the keyboard too. +// 2026-08-05 — finesse pass (TASK-14): the keyboard no longer auto-raises +// with the drawer; it belongs to the search pane and is summoned by tapping +// it (see oskSummoner). The drawer gained a translucent theme sheet +// (panelBg), the dock is suppressed while it is open, and both bodies fill +// 0.78 of the panel height instead of 0.7. +// +// While the OSK is open, this panel does NOT register with // GlobalFocusGrab.addDismissable — see the onOskOpenChanged handler below. // Reason: GlobalFocusGrab's HyprlandFocusGrab (hyprland_focus_grab_v1, a // real Wayland protocol) clears whenever a tap lands outside its @@ -21,6 +27,7 @@ import qs import qs.services import qs.modules.common import qs.modules.common.widgets +import qs.modules.common.functions as CF import Qt.labs.synchronizer import QtQuick import QtQuick.Controls @@ -34,6 +41,12 @@ import qs.modules.souveraine.navigation Scope { id: overviewScope property bool dontAutoCancelSearch: false + // Whether panelWindow is currently registered with GlobalFocusGrab so an + // outside tap dismisses the drawer. Gated on the OSK (see onOskOpenChanged) + // and deliberately tracked rather than add/remove blind: adding the same + // window twice is the double-add race the registration comment below warns + // about, and re-opening the drawer while already registered would re-add. + property bool panelDismissableRegistered: false PanelWindow { id: panelWindow @@ -60,7 +73,13 @@ Scope { color: "transparent" mask: Region { - item: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen) ? columnLayout : null + // The window only exists where the drawer does: panelBg (which + // wraps the column with a breathing margin) is the drawn surface + // AND the input region. This replaced `columnLayout` when the + // panel sheet was added — the mask must cover what panelBg + // covers, or the sheet renders clipped and its margin is dead + // input space. + item: panelBg } anchors { @@ -78,36 +97,72 @@ Scope { overviewScope.dontAutoCancelSearch = false; GlobalFocusGrab.dismiss(); GlobalStates.oskOpen = false; + // Undo the drawer-time suppression (see below) so the dock + // returns to its pre-drawer posture: its own swipe-down + // state, or the empty-desktop reveal it had coming. + GlobalStates.dockSuppressed = false; } else { if (!overviewScope.dontAutoCancelSearch) { searchWidget.cancelSearch(); } - // Search wants text input — bring up the on-screen - // keyboard automatically (Pixel3Arch, 2026-07-07). See - // overlays/quickshell-ii-patches/OnScreenKeyboard.qml. - // This also enters oskOpen=true, so the dismissable - // registration below (onOskOpenChanged) intentionally - // does NOT add panelWindow here — it's added only once - // oskOpen settles, to avoid a double-add race. - GlobalStates.oskOpen = true; + // Keyboard-less on open — 2026-08-05: the OSK no longer + // auto-raises with the drawer. The keyboard belongs to the + // search pane: tapping it summons the OSK (see oskSummoner + // below), tapping anything else doesn't. The field still + // auto-focuses so the first tap has somewhere to go. + GlobalStates.oskOpen = false; + // No OSK: the drawer is dismissable by outside tap, and it + // must register right here — onOskOpenChanged only fires on + // a keyboard toggle, which no longer happens on open. + if (!overviewScope.panelDismissableRegistered) { + GlobalFocusGrab.addDismissable(panelWindow); + overviewScope.panelDismissableRegistered = true; + } + // The drawer takes the space, so the dock goes down while + // it is open — and suppression, not dockRevealed=false: + // the dock is pinned on the phone, and a pinned dock + // ignores dockRevealed entirely (Dock.qml computeDockState: + // only dockSuppressed beats effectivePinned). The overview + // button lives on the dock, which is why the drawer needs + // other doors in (pill swipe home / IPC). The close branch + // above restores the flag. + GlobalStates.dockSuppressed = true; } } } // Registering as dismissable is gated on the OSK's state, not - // just overviewOpen — see the file-header comment for why. This - // fires both when overview opens (right after oskOpen flips true - // above) and whenever the OSK is toggled independently while - // overview stays open (e.g. closing the keyboard without closing - // search). + // just overviewOpen — see the file-header comment for why. The + // drawer registers itself in onOverviewOpenChanged (the OSK no + // longer flips on open); this handler only re-tracks when the OSK + // is toggled independently while the drawer stays open. + // One surface at a time. Home and the running-work view are opened by + // different gestures, but they are not modes to be stacked: a swipe to + // mission control while the drawer is up should replace it, not float + // the cards over the grid. Neither flag's writers enforce this, so the + // body's owner does. + Connections { + target: GlobalStates + function onOverviewOpenChanged() { + if (GlobalStates.overviewOpen) + GlobalStates.missionControlOpen = false; + } + function onMissionControlOpenChanged() { + if (GlobalStates.missionControlOpen) + GlobalStates.overviewOpen = false; + } + } + Connections { target: GlobalStates function onOskOpenChanged() { if (!GlobalStates.overviewOpen) return; if (GlobalStates.oskOpen) { GlobalFocusGrab.removeDismissable(panelWindow); - } else { + overviewScope.panelDismissableRegistered = false; + } else if (!overviewScope.panelDismissableRegistered) { GlobalFocusGrab.addDismissable(panelWindow); + overviewScope.panelDismissableRegistered = true; } } } @@ -127,8 +182,8 @@ Scope { } // Tap-to-dismiss for the dead space INSIDE the overview page: the - // window's input mask only covers columnLayout, and the workspace - // grid's gaps (between workspaces, around the grid) consume nothing — + // window's input mask only covers the panel sheet (panelBg), and the + // grid's gaps (between tiles, around the grid) consume nothing — // taps there used to be silently ignored, which read as the page // being stuck. Declared before (= stacked below) the column, sized to // the overview grid only, so the search bar's padding stays inert and @@ -152,6 +207,33 @@ Scope { } } + // The drawer's sheet. A solid, slightly translucent layer of the + // theme surface behind the search bar and grid — the "blur/look" + // finesse item. Deliberately NOT a real GaussianBlur: that needs + // Qt5Compat and dies on the phone's GLES-ish backend (2026-08-05), + // so this is the phone-safe substitute — an opaque-enough sheet that + // the wallpaper reads as a soft base behind it. Declared before the + // column (stacked below it) so nothing here can eat taps; it is + // input-transparent anyway. + Rectangle { + id: panelBg + visible: columnLayout.visible + anchors.fill: columnLayout + // The sheet breathes past the column so the drawer reads as a + // panel, not as widgets floating on the wallpaper. The mask + // follows this same box (see `mask` above). + anchors.leftMargin: -18 + anchors.rightMargin: -18 + anchors.topMargin: -6 + anchors.bottomMargin: -14 + radius: Appearance?.rounding?.large ?? 20 + // colLayer1 at ~88% — opaque enough that wallpaper detail behind + // the grid is noise, translucent enough to still be "surface". + color: CF.ColorUtils.transparentize(Appearance?.colors?.colLayer1 ?? "#1a1a1a", 0.12) + border.width: 1 + border.color: CF.ColorUtils.transparentize(Appearance?.colors?.colOnLayer1 ?? "#ffffff", 0.88) + } + Column { id: columnLayout // Both ways in. The panel, the mask and the loader were all moved @@ -191,21 +273,79 @@ Scope { anchors.horizontalCenter: parent.horizontalCenter active: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen) && (Config?.options.overview.enable ?? true) + // Two ways in, two bodies — TASK-14's split. The overview + // (Home) is the app drawer: search above (this file's + // SearchWidget) and AppGrid below. Mission control, raised by + // the pill's second-stage swipe, is the running-work view: + // WindowOverview's cards alone, deliberately no search and no + // keyboard (see `keyboardFocus` above). Previously this one + // body rendered the running cards for BOTH flags, which left + // Home showing what is running instead of what can run. + sourceComponent: GlobalStates.missionControlOpen + ? windowOverviewComponent : appGridComponent + } + + Component { + id: windowOverviewComponent // WindowOverview, not OverviewWidget. The latter draws a grid // of workspaces from HyprlandData; viewtop has neither, so it // rendered an empty frame and read as the overview being // broken. See WindowOverview.qml's header. - sourceComponent: WindowOverview { + WindowOverview { width: overviewLoader.parent.width - height: panelWindow.height * 0.7 + height: panelWindow.height * 0.78 visible: (panelWindow.searchingText == "") onActivated: toplevel => { toplevel?.activate(); GlobalStates.overviewOpen = false; + GlobalStates.missionControlOpen = false; } onClosed: toplevel => toplevel?.close() } } + + Component { + id: appGridComponent + // The phone app drawer. TASK-14: a paged grid from the + // desktop-entry list, alphabetical; search stays exactly + // as-is on top. See AppGrid.qml's header. + AppGrid { + width: overviewLoader.parent.width + height: panelWindow.height * 0.78 + visible: (panelWindow.searchingText == "") + } + } + } + // The search pane is now the keyboard's door (2026-08-05): the OSK + // no longer auto-raises with the drawer, it raises when this pane is + // TAPPED. This overlay sits above the search bar but outside its + // widget tree, so it can add behavior without forking SearchWidget: + // it re-focuses the field first (a tap on the grid may have left it) + // and only then opens the keyboard — the OSK's input redirection + // returns keys to whatever had focus before it opened, so focus must + // land BEFORE the keyboard does. The press propagates on afterwards, + // so the field still gets its normal tap. + MouseArea { + id: oskSummoner + enabled: GlobalStates.overviewOpen && !GlobalStates.oskOpen + visible: columnLayout.visible + // searchWidget is inside the column, not a sibling, so anchors + // cannot bind it — same idiom as the tap-to-dismiss MouseArea: + // geometry in columnLayout-local terms, offset by the column's + // own position within the panel. + x: columnLayout.x + searchWidget.x + y: columnLayout.y + searchWidget.y + width: searchWidget.width + height: searchWidget.height + acceptedButtons: Qt.LeftButton + propagateComposedEvents: true + onPressed: (mouse) => { + if (!GlobalStates.oskOpen) { + searchWidget.focusSearchInput(); + GlobalStates.oskOpen = true; + } + mouse.accepted = false; + } } } diff --git a/surfaces/quickshell/modules/ii/overview/qmldir b/surfaces/quickshell/modules/ii/overview/qmldir index c2796c4..680be97 100644 --- a/surfaces/quickshell/modules/ii/overview/qmldir +++ b/surfaces/quickshell/modules/ii/overview/qmldir @@ -1,3 +1,4 @@ +AppGrid 1.0 AppGrid.qml Overview 1.0 Overview.qml OverviewWidget 1.0 OverviewWidget.qml OverviewWindow 1.0 OverviewWindow.qml diff --git a/surfaces/quickshell/modules/settings/OverviewConfig.qml b/surfaces/quickshell/modules/settings/OverviewConfig.qml new file mode 100644 index 0000000..d13a694 --- /dev/null +++ b/surfaces/quickshell/modules/settings/OverviewConfig.qml @@ -0,0 +1,80 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +// Home screen (app drawer) — the TASK-14 split's launcher half. The drawer +// reads its grid straight from Config (see AppGrid.qml), so these knobs are +// the single source; the grid re-lays-out on the next drawer open. Rows and +// columns at the phone's 540x1080: 4 columns keeps a thumb travel across a +// page, 5 rows fills the panel without scrolling. The old desktop overview's +// rows/columns are untouched — this page owns Config.options.overview.appGrid. + +ContentPage { + forceWidth: true + + ContentSection { + icon: "grid_view" + title: Translation.tr("App drawer") + + ConfigSwitch { + buttonIcon: "apps" + text: Translation.tr("Enable drawer") + checked: Config.options.overview.enable + onCheckedChanged: { + Config.options.overview.enable = checked; + } + StyledToolTip { + text: Translation.tr("The Home surface: search on top, the app grid below. Off, the pill's swipe-home does nothing.") + } + } + + ConfigSpinBox { + icon: "view_column" + text: Translation.tr("Columns") + value: Config.options.overview.appGrid.columns + from: 1 + to: 10 + stepSize: 1 + onValueChanged: { + Config.options.overview.appGrid.columns = value; + } + StyledToolTip { + text: Translation.tr("Icons per row. More columns = denser grid and smaller tiles.") + } + } + + ConfigSpinBox { + icon: "view_agenda" + text: Translation.tr("Rows") + value: Config.options.overview.appGrid.rows + from: 1 + to: 10 + stepSize: 1 + onValueChanged: { + Config.options.overview.appGrid.rows = value; + } + StyledToolTip { + text: Translation.tr("Icon rows per page. More rows fills the screen; fewer leaves room for the page dots.") + } + } + + ConfigSpinBox { + icon: "photo_size_select_large" + text: Translation.tr("Icon size (px)") + value: Config.options.overview.appGrid.iconSize + from: 24 + to: 96 + stepSize: 4 + onValueChanged: { + Config.options.overview.appGrid.iconSize = value; + } + StyledToolTip { + text: Translation.tr("The icon glyph itself; labels always scale to the tile.") + } + } + } +} diff --git a/surfaces/quickshell/modules/settings/qmldir b/surfaces/quickshell/modules/settings/qmldir index 09a6ed8..0ced2af 100644 --- a/surfaces/quickshell/modules/settings/qmldir +++ b/surfaces/quickshell/modules/settings/qmldir @@ -12,6 +12,7 @@ KeyboardConfig 1.0 KeyboardConfig.qml LockConfig 1.0 LockConfig.qml NavigationConfig 1.0 NavigationConfig.qml NetworkConfig 1.0 NetworkConfig.qml +OverviewConfig 1.0 OverviewConfig.qml QuickConfig 1.0 QuickConfig.qml ServicesConfig 1.0 ServicesConfig.qml SettingsHome 1.0 SettingsHome.qml diff --git a/surfaces/quickshell/modules/souveraine/windowSheet/SheetButton.qml b/surfaces/quickshell/modules/souveraine/windowSheet/SheetButton.qml new file mode 100644 index 0000000..cd660df --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/windowSheet/SheetButton.qml @@ -0,0 +1,143 @@ +// One circular verb on the window action sheet. +// +// Sized against the selection chip's failure rather than against a desktop +// idea of a button: 76 px of circle and a 34 px glyph, because the chip +// shipped at 44 px with 18-20 px icons and was "completely untappable" on +// device, twice. Android's own floor for a touch target is 48 dp and that is +// a *minimum*, not a target, for a control the hand reaches for mid-gesture. +// +// The hold is optional and only Close uses it. It is a real press-and-hold +// rather than a second button because the polite and forceful versions of +// "close this" are one intent at two levels of insistence — and a separate +// always-kill button is one a hurried thumb presses meaning the safe one. +// The ring filling is the whole feedback story: a hold you only learn about +// on release cannot be abandoned halfway. +import QtQuick +import Quickshell +import qs.modules.common +import qs.modules.common.widgets + +Item { + id: root + + property string icon: "" + property string label: "" + property string holdLabel: "" + // 0 disables the hold entirely, which is the default: most verbs have no + // second level and should not appear to have one. + property int holdMs: 0 + + signal tapped + signal held + + readonly property int diameter: 76 + + implicitWidth: diameter + implicitHeight: diameter + 34 + + Rectangle { + id: circle + width: root.diameter + height: root.diameter + radius: width / 2 + anchors.horizontalCenter: parent.horizontalCenter + color: area.pressed + ? Appearance.colors.colLayer2 + : Appearance.colors.colLayer1 + border.width: 1 + // `colLayer1Active`, not `colLayer1Inactive` — the latter does not + // exist in Appearance and resolves to undefined, which Qt reports as + // "Unable to assign [undefined] to QColor" and then draws borderless. + // SubconsciousEventPanel.qml has the same typo and has been warning + // quietly ever since. + border.color: Appearance.colors.colLayer1Active + + scale: area.pressed ? 0.94 : 1.0 + Behavior on scale { NumberAnimation { duration: 90 } } + Behavior on color { ColorAnimation { duration: 90 } } + + MaterialSymbol { + anchors.centerIn: parent + text: root.icon + iconSize: 34 + color: Appearance.colors.colOnLayer1 + } + + // The hold's progress, drawn as a ring that closes. Only appears once + // a hold is actually running, so a tap never flashes it. + Canvas { + id: ring + anchors.fill: parent + visible: root.holdMs > 0 && hold.running + property real progress: 0 + + onProgressChanged: requestPaint() + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const r = width / 2 - 2; + ctx.beginPath(); + ctx.arc(width / 2, height / 2, r, -Math.PI / 2, + -Math.PI / 2 + Math.PI * 2 * ring.progress); + ctx.lineWidth = 3; + ctx.strokeStyle = Appearance.colors.colOnLayer1; + ctx.stroke(); + } + } + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: circle.bottom + anchors.topMargin: 8 + text: hold.running && root.holdLabel !== "" ? root.holdLabel : root.label + color: "#e6ffffff" + font.pixelSize: Appearance.font.pixelSize.small + } + + // Drives the ring and, at the end, the forceful verb. Started on press and + // stopped on release or on leaving the button, so sliding a thumb off is + // an abort rather than a commit. + NumberAnimation { + id: hold + target: ring + property: "progress" + from: 0 + to: 1 + duration: root.holdMs + onFinished: { + root.heldFired = true; + root.held(); + } + } + + property bool heldFired: false + + MouseArea { + id: area + anchors.fill: parent + + onPressed: { + root.heldFired = false; + if (root.holdMs > 0) + hold.start(); + } + + onReleased: { + if (root.holdMs > 0) + hold.stop(); + // A hold that completed already fired the forceful verb; releasing + // afterwards must not also fire the polite one. + if (!root.heldFired && containsMouse) + root.tapped(); + ring.progress = 0; + } + + onCanceled: { + hold.stop(); + ring.progress = 0; + } + + hoverEnabled: true + } +} diff --git a/surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml b/surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml new file mode 100644 index 0000000..7171706 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml @@ -0,0 +1,207 @@ +// The window action sheet — the hand's verbs on one window. +// +// TASK-55. Three fingers tapped a window; the compositor named which one, the +// machine decided what that means, and this draws the answer. The split is +// `gesture.rs`'s own: "the compositor recognises, the shell draws, and the +// answer comes back as the ToCompositor intents that already work". This file +// is the drawing half, and ViewtopControl is the answering half. +// +// ## Why a scrim and not a blur +// +// GaussianBlur fails on the phone's GLES path — the Home drawer hit exactly +// this on 2026-08-05 and fell back to a flat translucent panel (`panelBg`). +// Repeating a known-broken effect here would trade a working sheet for a +// black rectangle on the one device that matters. The scrim reads as "the app +// is behind this and paused" without asking the GPU for something it refuses. +// +// ## Why the buttons are this big +// +// The selection chip is the house precedent for a floating action surface and +// it failed twice on device — "tiny, illegible, not working properly at all" — +// at 44 px tall with 18-20 px icons. TASK-55 Q6 makes not inheriting that a +// condition of this shipping. So: 76 px circles, 34 px glyphs, 28 px between +// them, and the row sits above the vertical centre so a thumb reaching from +// the bottom bezel does not cover the window being acted on. +// +// ## Close, and the floor under it +// +// A tap is `close` — the protocol asking, which a client may refuse or answer +// with a save-your-work prompt. A press-and-hold is `kill`, which always +// works and loses unsaved work. Two verbs on one button because they are the +// same intent at two levels of insistence, and because a separate always-kill +// button would get pressed by someone who meant the polite one. +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 + +Scope { + id: scope + + // The window this sheet is about. 0 is "none" — the sheet is never open + // without a subject, which is what keeps the no-window case from becoming + // a blank sheet nobody can explain. + property int target: 0 + property bool sheetOpen: false + + // How long a hold on Close means kill. Longer than a tap could ever be, + // short enough that a stuck app does not feel like a negotiation. + readonly property int killHoldMs: 1000 + + function openFor(id) { + if (!id || id <= 0) { + console.log("[window-sheet] refusing to open without a target"); + return; + } + scope.target = id; + scope.sheetOpen = true; + } + + function dismiss() { + scope.sheetOpen = false; + scope.target = 0; + } + + // sessiond's `Action::WindowSheet` lands here: `qs -c souveraine ipc call + // windowSheet open `. The id arrives as a string because the executor + // builds a command line; parsing is this side's job. + IpcHandler { + target: "windowSheet" + + function open(id: string): void { + scope.openFor(parseInt(id, 10)); + } + + function close(): void { + scope.dismiss(); + } + } + + // A refusal the hand can see. `close` is a request, and a client that says + // no must not look like a button that did nothing — that silence is the + // complaint this whole task came from. + Connections { + target: ViewtopControl + function onRefused(intent, reason) { + if (!scope.sheetOpen) + return; + refusal.text = intent + " refused: " + reason; + refusal.opacity = 1; + refusalFade.restart(); + } + function onSucceeded(intent) { + // The window is gone or moved; the sheet has nothing left to be + // about. Kept open for `pose`-style verbs would mean a sheet + // pointing at a window that is no longer where it was. + if (intent === "close" || intent === "kill" || intent === "place") + scope.dismiss(); + } + } + + 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.sheetOpen + WlrLayershell.namespace: "souveraine:windowsheet" + WlrLayershell.layer: WlrLayer.Overlay + // OnDemand rather than Exclusive: an exclusive grab on the polkit + // surface stopped touch reaching the layer below it entirely + // (2026-07-26), and this surface must not repeat that. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + exclusionMode: ExclusionMode.Ignore + + // The scrim. Also the outside-tap target: dismissal by tapping away + // is load-bearing on a phone, and it is the only exit that needs no + // explanation. + Rectangle { + anchors.fill: parent + color: "#99000000" + + MouseArea { + anchors.fill: parent + onClicked: scope.dismiss() + } + + Behavior on opacity { NumberAnimation { duration: 120 } } + } + + // The row. Above centre so a thumb does not cover the window. + RowLayout { + anchors.horizontalCenter: parent.horizontalCenter + y: parent.height * 0.38 + spacing: 28 + + SheetButton { + icon: "open_in_full" + label: "Resize" + onTapped: { + // Half height, full width, parked at the top — the + // `place` path, and the one geometry that is always + // legal on a phone. A drag-to-size handle is the real + // answer and it needs a gesture of its own; this is the + // verb reaching the window, which is what was missing. + ViewtopControl.place(scope.target, 0, 0, win.width, win.height / 2); + } + } + + SheetButton { + icon: "splitscreen" + label: "Split" + // Split pins this window to one half and the other half + // wants a chooser — open apps from the overview, or the + // app list. That chooser is the missing half and it has no + // verb yet, so this places the window and says so rather + // than pretending the flow is finished. + onTapped: { + ViewtopControl.place(scope.target, 0, 0, win.width, win.height / 2); + refusal.text = "pinned to the top half — chooser for the other half is not built yet"; + refusal.opacity = 1; + refusalFade.restart(); + } + } + + SheetButton { + icon: "close" + label: "Close" + holdLabel: "hold to force" + holdMs: scope.killHoldMs + onTapped: ViewtopControl.close(scope.target) + onHeld: ViewtopControl.kill(scope.target) + } + } + + StyledText { + id: refusal + anchors.horizontalCenter: parent.horizontalCenter + y: parent.height * 0.38 + 150 + width: parent.width * 0.8 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + color: "#e6ffffff" + font.pixelSize: Appearance.font.pixelSize.small + opacity: 0 + text: "" + + Behavior on opacity { NumberAnimation { duration: 150 } } + + Timer { + id: refusalFade + interval: 2600 + onTriggered: refusal.opacity = 0 + } + } + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/windowSheet/qmldir b/surfaces/quickshell/modules/souveraine/windowSheet/qmldir new file mode 100644 index 0000000..9f40d4d --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/windowSheet/qmldir @@ -0,0 +1,2 @@ +SheetButton 1.0 SheetButton.qml +WindowSheet 1.0 WindowSheet.qml diff --git a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml index 538f158..f4a7e30 100644 --- a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml +++ b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml @@ -29,6 +29,7 @@ import qs.modules.ii.wallpaperSelector import qs.modules.souveraine.dial import qs.modules.souveraine.navigation import qs.modules.souveraine.selection +import qs.modules.souveraine.windowSheet // The Souveraine panel family — one family for both modes. // Starts at exact panel parity with IllogicalImpulseFamily (our overridden @@ -80,6 +81,10 @@ Scope { PanelLoader { component: Overview {} } PanelLoader { component: Polkit {} } PanelLoader { component: DialHost {} } + // The three-finger tap's surface (TASK-55). Ungated: it is the only way + // the hand can close a window under viewtop, and the dial's own "Kill + // window" is a dead `hyprctl dispatch`. + PanelLoader { component: WindowSheet {} } // 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. diff --git a/surfaces/quickshell/services/ViewtopControl.qml b/surfaces/quickshell/services/ViewtopControl.qml new file mode 100644 index 0000000..dd66d6c --- /dev/null +++ b/surfaces/quickshell/services/ViewtopControl.qml @@ -0,0 +1,174 @@ +// The shell's door into viewtop's control socket. +// +// The compositor serves one JSON object per line — `{"op":…}` in, +// `{"ok":true,…}` or `{"ok":false,"code":…,"reason":…}` back — the same house +// grammar sessiond speaks, so this is shaped like SessiondPolicy rather than +// inventing a second idea of what talking to a daemon looks like. +// +// **This is the only place the shell addresses the scene.** Every window verb +// the hand can reach — close, kill, place, pose, raise, focus — goes through +// `scene()`. The alternative is what the dial does today: `hyprctl dispatch` +// baked into a surface, which stopped existing under viewtop and took the +// dial's "Kill window" with it. A verb spelled out inside a widget is a verb +// that dies when the compositor changes. +// +// A short-lived connection per request, deliberately. viewtop's socket is +// request/response and holds no session; there is no heartbeat to protect here +// the way SessiondBridge's EOF is load-bearing, so nothing is gained by +// keeping it open and a dropped long-lived socket would need reconnect logic +// to be correct. +// +// Failures are LOUD. A window verb that silently did nothing is exactly the +// bug this exists to close: `overview toggle` shelled out to a handler that +// did not exist and the tap did nothing, silently, for weeks. +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + // The last refusal, for a surface that wants to show one. `close` is a + // request the client may refuse; the hand deserves to see that rather than + // watch nothing happen. + property string lastError: "" + + signal refused(string intent, string reason) + signal succeeded(string intent) + + // Requests waiting on a connection. A queue rather than SessiondPolicy's + // single slot: the sheet can fire two verbs in a row (kill after a close + // that was refused), and dropping the second would be silent. + property var _queue: [] + property var _inflight: null + + function _send(msg) { + root._queue.push(msg); + if (sock.connected) + root._drain(); + else + sock.connected = true; + } + + function _drain() { + if (root._inflight !== null || root._queue.length === 0) + return; + root._inflight = root._queue.shift(); + sock.write(JSON.stringify(root._inflight) + "\n"); + } + + // Address the scene. `intent` is one of the compositor's own — the table it + // returns from `{"op":"describe"}` is authoritative, and this deliberately + // does not keep a second copy of it to validate against. + function scene(intent, args) { + const msg = { op: "scene", intent: intent }; + for (const k in args) + msg[k] = args[k]; + root._send(msg); + } + + // Ask the client to close. It may refuse or prompt; that is the protocol, + // not a bug, and `refused` carries it. + function close(id) { + root.scene("close", { id: id }); + } + + // End it regardless. The floor under close(), for a client that is hung or + // says no — "two apps and no way to turn them off" is what this answers. + // Unsaved work is lost, so a surface offering this should mean it. + function kill(id) { + root.scene("kill", { id: id }); + } + + // Position and size a window. The shell owns layout; the compositor owns + // whether a placement is legal and will refuse one that is not. + function place(id, x, y, width, height) { + root.scene("place", { + id: id, + at: { x: x, y: y }, + size: { width: width, height: height } + }); + } + + // Give a window back to the layout. + function unplace(id) { + root.scene("unplace", { id: id }); + } + + // Compose a window's visual geometry — the verb that makes a live app a + // scaled, floating, still-touchable thing rather than a picture of one. + // The compositor maps input back through the inverse, so a shrunken window + // still receives touch where it is drawn. + function pose(id, scale, rotation, anchorX, anchorY) { + root.scene("pose", { + id: id, + scale: scale, + rotation: rotation ?? 0.0, + anchor: { x: anchorX ?? 0.5, y: anchorY ?? 0.5 } + }); + } + + function raise(id) { + root.scene("raise", { id: id }); + } + + function focus(id) { + root.scene("focus", { id: id }); + } + + Socket { + id: sock + path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock" + + onConnectionStateChanged: { + if (connected) { + root._drain(); + } else if (root._inflight !== null || root._queue.length > 0) { + const intent = (root._inflight && root._inflight.intent) || "?"; + root.lastError = "viewtop socket unavailable"; + console.error("[viewtop-control] could not reach the compositor at " + + sock.path + " — the scene was NOT changed"); + root._inflight = null; + root._queue = []; + root.refused(intent, root.lastError); + } + } + + parser: SplitParser { + splitMarker: "\n" + onRead: message => { + const sent = root._inflight; + const intent = (sent && sent.intent) || "?"; + root._inflight = null; + + let reply; + try { + reply = JSON.parse(message); + } catch (e) { + console.error("[viewtop-control] unparseable reply: " + message); + root.lastError = "unparseable reply"; + root.refused(intent, root.lastError); + root._drain(); + return; + } + + if (reply.ok !== true) { + // `gone` is the ordinary one: the window closed between the + // sheet opening and the button being pressed. Still a + // refusal, still surfaced, because a sheet acting on a dead + // id should say so rather than appear to work. + const why = reply.reason || reply.code || "refused without a reason"; + console.log("[viewtop-control] " + intent + " refused: " + why); + root.lastError = why; + root.refused(intent, why); + } else { + root.lastError = ""; + root.succeeded(intent); + } + root._drain(); + } + } + } +} diff --git a/surfaces/quickshell/services/qmldir b/surfaces/quickshell/services/qmldir index bc4d571..5742392 100644 --- a/surfaces/quickshell/services/qmldir +++ b/surfaces/quickshell/services/qmldir @@ -62,6 +62,7 @@ singleton Todo 1.0 Todo.qml singleton Translation 1.0 Translation.qml singleton TrayService 1.0 TrayService.qml singleton Updates 1.0 Updates.qml +singleton ViewtopControl 1.0 ViewtopControl.qml singleton WallpaperAssets 1.0 WallpaperAssets.qml singleton WallpaperDownload 1.0 WallpaperDownload.qml singleton Wallpapers 1.0 Wallpapers.qml diff --git a/surfaces/quickshell/settings.qml b/surfaces/quickshell/settings.qml index 3ec958e..732e5a1 100644 --- a/surfaces/quickshell/settings.qml +++ b/surfaces/quickshell/settings.qml @@ -70,6 +70,11 @@ ApplicationWindow { icon: "wallpaper", component: "modules/settings/WallpaperConfig.qml" }, + { + name: Translation.tr("Home screen"), + icon: "apps", + component: "modules/settings/OverviewConfig.qml" + }, { name: Translation.tr("Dock"), icon: "dock_to_bottom",