Watch
1
0
Fork
You've already forked souveraine
0

three-finger tap opens a window sheet, not the overview

This commit is contained in:
Fimeg 2026-08-05 14:12:22 -04:00
commit bf8bc013ef
21 changed files with 1476 additions and 49 deletions

View file

@ -408,7 +408,7 @@ pub enum Action {
/// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the /// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the
/// panel, only sessiond moves the lock". /// panel, only sessiond moves the lock".
Unblank, 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 /// A touch gesture bound the way every other control is: viewtop reports
/// what the fingers did, the machine decides what it means, and the /// 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 /// The compositor has to do the *recognition* because contacts only exist
/// there, which is why this arrives already named rather than as raw /// there, which is why this arrives already named rather than as raw
/// touches. What it must not do is decide what a name means. /// 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 /// Request the session lock, because something wants the panel dark and
/// the session is not locked yet. /// 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 /// chose is worse than one that fires nothing, and the binding table this
/// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and /// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and
/// agent-writable, so a triple-tap can be re-pointed without a rebuild. /// agent-writable, so a triple-tap can be re-pointed without a rebuild.
pub fn touch_gesture(&mut self, fingers: u8, gesture: TouchGesture) -> Vec<Action> { pub fn touch_gesture(
let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap); &mut self,
fingers: u8,
gesture: TouchGesture,
target: Option<u64>,
) -> Vec<Action> {
// 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( self.record_decision(
"touch-gesture", "touch-gesture",
serde_json::json!({ serde_json::json!({
"fingers": fingers, "fingers": fingers,
"gesture": gesture.as_str(), "gesture": gesture.as_str(),
"target": target,
"bound": bound, "bound": bound,
}), }),
"touch gesture recognised", "touch gesture recognised",
@ -1728,14 +1748,20 @@ impl DeviceStateMachine {
if !bound { if !bound {
return Vec::new(); 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 // 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 // exist to prevent. A tap on a dark panel is a wake, and that is the
// power button's business, not this one's. // power button's business, not this one's.
if !self.panel_on { if !self.panel_on {
return Vec::new(); 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<Action> { pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
@ -3205,10 +3231,19 @@ mod tests {
} }
#[test] #[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 (mut sm, _t0) = unlocked_and_lit();
let actions = sm.touch_gesture(3, TouchGesture::Tap); let actions = sm.touch_gesture(3, TouchGesture::Tap, Some(7));
assert_eq!(actions, vec![Action::Overview]); 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] #[test]
@ -3223,24 +3258,25 @@ mod tests {
TouchGesture::SwipeLeft, TouchGesture::SwipeLeft,
TouchGesture::SwipeRight, 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] { for fingers in [1u8, 2, 4, 5] {
assert!( assert!(
sm.touch_gesture(fingers, TouchGesture::Tap).is_empty(), sm.touch_gesture(fingers, TouchGesture::Tap, Some(1))
.is_empty(),
"{fingers} fingers" "{fingers} fingers"
); );
} }
} }
#[test] #[test]
fn the_overview_does_not_open_on_a_dark_panel() { fn the_sheet_does_not_open_on_a_dark_panel() {
// The overview is content, and content on an unauthenticated glass is // 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 // what the disclosure rules exist to prevent. A tap on a dark panel is
// a wake, and that is the power button's business. // a wake, and that is the power button's business.
let (mut sm, _t0) = unlocked_and_lit(); let (mut sm, _t0) = unlocked_and_lit();
sm.set_panel(false); 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] #[test]

View file

@ -186,7 +186,7 @@ pub const VERBS: &[VerbDoc] = &[
mutates: true, mutates: true,
summary: "a recognised touch gesture; the machine decides what it means", summary: "a recognised touch gesture; the machine decides what it means",
refuses: &[RefusalCode::InvalidArgument], refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"gesture","fingers":3,"gesture":"tap"}"#, example: r#"{"op":"gesture","fingers":3,"gesture":"tap","target":1}"#,
}, },
VerbDoc { VerbDoc {
op: "panel", op: "panel",
@ -315,7 +315,20 @@ pub enum Request {
/// recognise them. The line §12 draws still holds — the compositor names /// recognise them. The line §12 draws still holds — the compositor names
/// what the fingers did and the machine decides what it means. A compositor /// what the fingers did and the machine decides what it means. A compositor
/// that both recognised and acted would be the eighth blind actor. /// 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<u64>,
},
/// The DPMS executor reports the panel's real power state. The machine /// 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" /// 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 /// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark

View file

@ -341,6 +341,11 @@ fn execute(shared: &Arc<Shared>, action: Action) {
return; 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 { let (program, args, label): (&str, Vec<&str>, &str) = match action {
// sessiond guarantees this runs once per dim, which is what the old // sessiond guarantees this runs once per dim, which is what the old
// hypridle `-s`/`-r` listener could not: a second save while already // hypridle `-s`/`-r` listener could not: a second save while already
@ -372,14 +377,26 @@ fn execute(shared: &Arc<Shared>, action: Action) {
vec!["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"], vec!["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"],
"volume-down", "volume-down",
), ),
// The shell owns the overview; the machine owns what opens it. Same // The shell owns the sheet; the machine owns what opens it, and for
// shape as every other row here — a named tool with named arguments, // which window. Same shape as every other row — a named tool with
// one place, in the trail. // named arguments, one place, in the trail — except that the window is
Action::Overview => ( // an argument, because a sheet with no subject is the bug this replaced.
"qs", Action::WindowSheet { target } => {
vec!["-c", "souveraine", "ipc", "call", "overview", "toggle"], sheet_target = target.to_string();
"overview-toggle", (
), "qs",
vec![
"-c",
"souveraine",
"ipc",
"call",
"windowSheet",
"open",
&sheet_target,
],
"window-sheet-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
@ -1256,10 +1273,14 @@ fn handle_request(
} }
serde_json::json!({ "ok": true }) serde_json::json!({ "ok": true })
} }
Request::Gesture { fingers, gesture } => { Request::Gesture {
fingers,
gesture,
target,
} => {
let actions = { let actions = {
let mut d = shared.lock(); 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(); let bound = !actions.is_empty();
for action in actions { for action in actions {

View file

@ -74,6 +74,11 @@ ApplicationWindow {
icon: "wallpaper", icon: "wallpaper",
component: "modules/settings/WallpaperConfig.qml" component: "modules/settings/WallpaperConfig.qml"
}, },
{
name: Translation.tr("Home screen"),
icon: "apps",
component: "modules/settings/OverviewConfig.qml"
},
{ {
name: Translation.tr("Dock"), name: Translation.tr("Dock"),
icon: "dock_to_bottom", icon: "dock_to_bottom",

View file

@ -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/SelectionAction.qml souveraine/modules/souveraine/selection/SelectionAction.qml
modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml
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/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/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml
modules/souveraine/subconscious/qmldir souveraine/modules/souveraine/subconscious/qmldir 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/functions/Session.qml souveraine/modules/common/functions/Session.qml
modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.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/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/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
modules/settings/NetworkConfig.qml souveraine/modules/settings/NetworkConfig.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/SoundConfig.qml souveraine/modules/settings/SoundConfig.qml
modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
modules/settings/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.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/DockConfig.qml souveraine/modules/settings/DockConfig.qml
modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml
modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.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/AppInventory.qml souveraine/modules/ii/appInventory/AppInventory.qml
modules/ii/appInventory/AppInventoryScope.qml souveraine/modules/ii/appInventory/AppInventoryScope.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/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/screenCorners/ScreenCorners.qml souveraine/modules/ii/screenCorners/ScreenCorners.qml
modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml
modules/common/Persistent.qml souveraine/modules/common/Persistent.qml modules/common/Persistent.qml souveraine/modules/common/Persistent.qml

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
if [[ -z "$1" ]]; then
echo "Usage: $0 <image_path> [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'

View file

@ -179,7 +179,17 @@ Singleton {
property string style: "cookie" // Options: "cookie", "digital" property string style: "cookie" // Options: "cookie", "digital"
property string styleLocked: "cookie" // Options: "cookie", "digital" property string styleLocked: "cookie" // Options: "cookie", "digital"
property JsonObject cookie: JsonObject { 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 aiStyling: false
property bool aiPreset: false
property int sides: 14 property int sides: 14
property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none" property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none"
property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide" property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide"
@ -612,6 +622,15 @@ Singleton {
property bool orderRightLeft: false property bool orderRightLeft: false
property bool orderBottomUp: false property bool orderBottomUp: false
property bool centerIcons: true 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 { property JsonObject regionSelector: JsonObject {

View file

@ -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
}
}
}
}
}

View file

@ -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<string> 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
}
}
}

View file

@ -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 } }
}
}
}
}

View file

@ -1,8 +1,14 @@
// Pixel3Arch patch to ii's stock Overview.qml (2026-07-07): opening the // Pixel3Arch patch to ii's stock Overview.qml. History:
// search/overview automatically brings up the on-screen keyboard
// (GlobalStates.oskOpen), and closing it closes the keyboard too.
// //
// 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. // GlobalFocusGrab.addDismissable see the onOskOpenChanged handler below.
// Reason: GlobalFocusGrab's HyprlandFocusGrab (hyprland_focus_grab_v1, a // Reason: GlobalFocusGrab's HyprlandFocusGrab (hyprland_focus_grab_v1, a
// real Wayland protocol) clears whenever a tap lands outside its // real Wayland protocol) clears whenever a tap lands outside its
@ -21,6 +27,7 @@ import qs
import qs.services import qs.services
import qs.modules.common import qs.modules.common
import qs.modules.common.widgets import qs.modules.common.widgets
import qs.modules.common.functions as CF
import Qt.labs.synchronizer import Qt.labs.synchronizer
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
@ -34,6 +41,12 @@ import qs.modules.souveraine.navigation
Scope { Scope {
id: overviewScope id: overviewScope
property bool dontAutoCancelSearch: false 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 { PanelWindow {
id: panelWindow id: panelWindow
@ -60,7 +73,13 @@ Scope {
color: "transparent" color: "transparent"
mask: Region { 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 { anchors {
@ -78,36 +97,72 @@ Scope {
overviewScope.dontAutoCancelSearch = false; overviewScope.dontAutoCancelSearch = false;
GlobalFocusGrab.dismiss(); GlobalFocusGrab.dismiss();
GlobalStates.oskOpen = false; 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 { } else {
if (!overviewScope.dontAutoCancelSearch) { if (!overviewScope.dontAutoCancelSearch) {
searchWidget.cancelSearch(); searchWidget.cancelSearch();
} }
// Search wants text input bring up the on-screen // Keyboard-less on open 2026-08-05: the OSK no longer
// keyboard automatically (Pixel3Arch, 2026-07-07). See // auto-raises with the drawer. The keyboard belongs to the
// overlays/quickshell-ii-patches/OnScreenKeyboard.qml. // search pane: tapping it summons the OSK (see oskSummoner
// This also enters oskOpen=true, so the dismissable // below), tapping anything else doesn't. The field still
// registration below (onOskOpenChanged) intentionally // auto-focuses so the first tap has somewhere to go.
// does NOT add panelWindow here it's added only once GlobalStates.oskOpen = false;
// oskOpen settles, to avoid a double-add race. // No OSK: the drawer is dismissable by outside tap, and it
GlobalStates.oskOpen = true; // 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 // Registering as dismissable is gated on the OSK's state, not
// just overviewOpen see the file-header comment for why. This // just overviewOpen see the file-header comment for why. The
// fires both when overview opens (right after oskOpen flips true // drawer registers itself in onOverviewOpenChanged (the OSK no
// above) and whenever the OSK is toggled independently while // longer flips on open); this handler only re-tracks when the OSK
// overview stays open (e.g. closing the keyboard without closing // is toggled independently while the drawer stays open.
// search). // 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 { Connections {
target: GlobalStates target: GlobalStates
function onOskOpenChanged() { function onOskOpenChanged() {
if (!GlobalStates.overviewOpen) return; if (!GlobalStates.overviewOpen) return;
if (GlobalStates.oskOpen) { if (GlobalStates.oskOpen) {
GlobalFocusGrab.removeDismissable(panelWindow); GlobalFocusGrab.removeDismissable(panelWindow);
} else { overviewScope.panelDismissableRegistered = false;
} else if (!overviewScope.panelDismissableRegistered) {
GlobalFocusGrab.addDismissable(panelWindow); GlobalFocusGrab.addDismissable(panelWindow);
overviewScope.panelDismissableRegistered = true;
} }
} }
} }
@ -127,8 +182,8 @@ Scope {
} }
// Tap-to-dismiss for the dead space INSIDE the overview page: the // Tap-to-dismiss for the dead space INSIDE the overview page: the
// window's input mask only covers columnLayout, and the workspace // window's input mask only covers the panel sheet (panelBg), and the
// grid's gaps (between workspaces, around the grid) consume nothing // grid's gaps (between tiles, around the grid) consume nothing
// taps there used to be silently ignored, which read as the page // taps there used to be silently ignored, which read as the page
// being stuck. Declared before (= stacked below) the column, sized to // being stuck. Declared before (= stacked below) the column, sized to
// the overview grid only, so the search bar's padding stays inert and // 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 { Column {
id: columnLayout id: columnLayout
// Both ways in. The panel, the mask and the loader were all moved // Both ways in. The panel, the mask and the loader were all moved
@ -191,21 +273,79 @@ Scope {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
active: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen) active: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen)
&& (Config?.options.overview.enable ?? true) && (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 // WindowOverview, not OverviewWidget. The latter draws a grid
// of workspaces from HyprlandData; viewtop has neither, so it // of workspaces from HyprlandData; viewtop has neither, so it
// rendered an empty frame and read as the overview being // rendered an empty frame and read as the overview being
// broken. See WindowOverview.qml's header. // broken. See WindowOverview.qml's header.
sourceComponent: WindowOverview { WindowOverview {
width: overviewLoader.parent.width width: overviewLoader.parent.width
height: panelWindow.height * 0.7 height: panelWindow.height * 0.78
visible: (panelWindow.searchingText == "") visible: (panelWindow.searchingText == "")
onActivated: toplevel => { onActivated: toplevel => {
toplevel?.activate(); toplevel?.activate();
GlobalStates.overviewOpen = false; GlobalStates.overviewOpen = false;
GlobalStates.missionControlOpen = false;
} }
onClosed: toplevel => toplevel?.close() 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;
}
} }
} }

View file

@ -1,3 +1,4 @@
AppGrid 1.0 AppGrid.qml
Overview 1.0 Overview.qml Overview 1.0 Overview.qml
OverviewWidget 1.0 OverviewWidget.qml OverviewWidget 1.0 OverviewWidget.qml
OverviewWindow 1.0 OverviewWindow.qml OverviewWindow 1.0 OverviewWindow.qml

View file

@ -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.")
}
}
}
}

View file

@ -12,6 +12,7 @@ KeyboardConfig 1.0 KeyboardConfig.qml
LockConfig 1.0 LockConfig.qml LockConfig 1.0 LockConfig.qml
NavigationConfig 1.0 NavigationConfig.qml NavigationConfig 1.0 NavigationConfig.qml
NetworkConfig 1.0 NetworkConfig.qml NetworkConfig 1.0 NetworkConfig.qml
OverviewConfig 1.0 OverviewConfig.qml
QuickConfig 1.0 QuickConfig.qml QuickConfig 1.0 QuickConfig.qml
ServicesConfig 1.0 ServicesConfig.qml ServicesConfig 1.0 ServicesConfig.qml
SettingsHome 1.0 SettingsHome.qml SettingsHome 1.0 SettingsHome.qml

View file

@ -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
}
}

View file

@ -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 <id>`. 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
}
}
}
}
}

View file

@ -0,0 +1,2 @@
SheetButton 1.0 SheetButton.qml
WindowSheet 1.0 WindowSheet.qml

View file

@ -29,6 +29,7 @@ import qs.modules.ii.wallpaperSelector
import qs.modules.souveraine.dial import qs.modules.souveraine.dial
import qs.modules.souveraine.navigation import qs.modules.souveraine.navigation
import qs.modules.souveraine.selection import qs.modules.souveraine.selection
import qs.modules.souveraine.windowSheet
// The Souveraine panel family one family for both modes. // The Souveraine panel family one family for both modes.
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden // Starts at exact panel parity with IllogicalImpulseFamily (our overridden
@ -80,6 +81,10 @@ Scope {
PanelLoader { component: Overview {} } PanelLoader { component: Overview {} }
PanelLoader { component: Polkit {} } PanelLoader { component: Polkit {} }
PanelLoader { component: DialHost {} } 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 // 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.

View file

@ -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();
}
}
}
}

View file

@ -62,6 +62,7 @@ singleton Todo 1.0 Todo.qml
singleton Translation 1.0 Translation.qml singleton Translation 1.0 Translation.qml
singleton TrayService 1.0 TrayService.qml singleton TrayService 1.0 TrayService.qml
singleton Updates 1.0 Updates.qml singleton Updates 1.0 Updates.qml
singleton ViewtopControl 1.0 ViewtopControl.qml
singleton WallpaperAssets 1.0 WallpaperAssets.qml singleton WallpaperAssets 1.0 WallpaperAssets.qml
singleton WallpaperDownload 1.0 WallpaperDownload.qml singleton WallpaperDownload 1.0 WallpaperDownload.qml
singleton Wallpapers 1.0 Wallpapers.qml singleton Wallpapers 1.0 Wallpapers.qml

View file

@ -70,6 +70,11 @@ ApplicationWindow {
icon: "wallpaper", icon: "wallpaper",
component: "modules/settings/WallpaperConfig.qml" component: "modules/settings/WallpaperConfig.qml"
}, },
{
name: Translation.tr("Home screen"),
icon: "apps",
component: "modules/settings/OverviewConfig.qml"
},
{ {
name: Translation.tr("Dock"), name: Translation.tr("Dock"),
icon: "dock_to_bottom", icon: "dock_to_bottom",