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

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

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
// 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;
}
}
}

View file

@ -1,3 +1,4 @@
AppGrid 1.0 AppGrid.qml
Overview 1.0 Overview.qml
OverviewWidget 1.0 OverviewWidget.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
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

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