Watch
1
0
Fork
You've already forked souveraine
0

quickshell: dock geometry/icon-centering, GlobalStates, ConflictKiller service

Dock + pill z-order and icon centering pass; GlobalStates.qml and a
ConflictKiller service for the ii sidebar.
This commit is contained in:
Fimeg 2026-07-12 14:56:56 -04:00
commit ec7c4e8e8c
9 changed files with 330 additions and 107 deletions

View file

@ -0,0 +1,100 @@
// Souveraine patch to ii's stock GlobalStates.qml.
//
// Adds dockRevealPulse: a self-clearing "show the dock for a few seconds"
// trigger, driven by the gesture pill (pill/shell.qml) via the dock IPC
// while the on-screen keyboard has suppressed the dock (see Dock.qml's
// computeDockState). Everything else in this file is unchanged stock ii
// diff against upstream before re-applying this patch if ii updates.
import qs.modules.common
import qs.services
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property bool barOpen: true
property bool crosshairOpen: false
property bool sidebarLeftOpen: false
property bool sidebarRightOpen: false
property bool mediaControlsOpen: false
property bool osdBrightnessOpen: false
property bool osdVolumeOpen: false
property bool oskOpen: false
property bool overlayOpen: false
property bool overviewOpen: false
property bool regionSelectorOpen: false
property bool searchOpen: false
property bool screenLocked: false
property bool screenLockContainsCharacters: false
property bool screenUnlockFailed: false
property bool screenTranslatorOpen: false
property bool sessionOpen: false
property bool superDown: false
property bool superReleaseMightTrigger: true
property real superPressTime: 0
property real superLastPressDuration: -1
property real superLastReleaseTime: 0
property bool wallpaperSelectorOpen: false
property bool workspaceShowNumbers: false
property bool dockRevealPulse: false
function pulseDockReveal() {
root.dockRevealPulse = true;
dockRevealPulseTimer.restart();
}
Timer {
id: dockRevealPulseTimer
interval: 3000
onTriggered: root.dockRevealPulse = false
}
function superPressDuration() {
const now = Date.now();
if (root.superPressTime > 0)
return now - root.superPressTime;
if (root.superLastReleaseTime > 0 && now - root.superLastReleaseTime < 250)
return root.superLastPressDuration;
return -1;
}
function shouldSuppressSuperReleaseSearch() {
const autoHide = Config?.options?.bar?.autoHide;
const showWhenPressingSuper = autoHide?.showWhenPressingSuper;
if (!autoHide?.enable || !showWhenPressingSuper?.enable || !showWhenPressingSuper?.suppressSearchOnHold)
return false;
const duration = root.superPressDuration();
return duration >= (showWhenPressingSuper?.suppressSearchDelay ?? showWhenPressingSuper?.delay ?? 140);
}
onSidebarRightOpenChanged: {
if (GlobalStates.sidebarRightOpen) {
Notifications.timeoutAll();
Notifications.markAllRead();
}
}
GlobalShortcut {
name: "workspaceNumber"
description: "Hold to show workspace numbers, release to show icons"
onPressed: {
root.superDown = true
root.superPressTime = Date.now()
root.superLastPressDuration = -1
}
onReleased: {
const now = Date.now()
if (root.superPressTime > 0)
root.superLastPressDuration = now - root.superPressTime
root.superLastReleaseTime = now
root.superPressTime = 0
root.superDown = false
}
}
}

View file

@ -24,14 +24,26 @@ Edit here → `./deploy.sh --phone` → restart. Deployed via symlink; live edit
4. empty desktop / no focused app → **Peek**
5. else (normal app focused) → **Hidden**
- **Reserves 32px pill strip** at the bottom so it never covers the pill.
- Depends on **`GlobalStates.dockRevealPulse` + `pulseDockReveal()`** — MUST be in the surface tree + deploy manifest (currently drift-only on phone; see gap below).
- **Bar height is content-driven**: the window sizes itself to the button row (64px buttons) + row margin + pill strip; `Config.options.dock.height` is only a floor. Don't tune the config height to "fix" icon clipping.
- **App list width is capped** to the screen (`DockApps.maxWidth`); past that the icon row scrolls horizontally by touch (flick is only enabled when overflowing, so drag-to-combine keeps working when everything fits).
- **DockStack renders with the same content block as DockAppButton** (icon + half-reserved dot strip, centered as one unit). Keep them structurally identical or they drift apart on the bar.
- Depends on **`GlobalStates.dockRevealPulse` + `pulseDockReveal()`** — lives in the surface tree (`GlobalStates.qml`, stock ii + pulse patch) and the deploy manifest. If ii updates its GlobalStates, re-diff and re-apply the patch.
## souveraine-settings (planned, not built)
A future settings app for the phone shell. Planned sections:
- **Dock** — pinned apps, height floor, monochrome icons, ignored-app regexes (today: hand-edited in `~/.config/illogical-impulse/config.json`).
- **Stacks** — create/rename/reorder app stacks and their members (today: `dock.stacks` strings in config.json, or drag-to-combine on the dock).
The dock already reserves its entry points: the long-press menu's "App settings…"
and the `dockSettings` IPC target (`openApp(appId)` / `open()`) in `Dock.qml`.
Both are STUBS — they log and pulse the dock, nothing opens. That's deliberate:
the IPC name stays stable so the settings app can take it over without touching
the dock.
## Fullscreen modes (Hyprland 0.55, lua dispatch)
- Dispatch: `hl.dsp.window.fullscreen({ window="address:0x…", mode=N })`. Classic `fullscreen 1` errors.
- **mode 0** = app-mode: whole display, no border/gaps, bar+pill stay (Overlay outranks it). ← the pill uses this.
- **mode 1** = maximize but keeps 20px gaps + 2px border.
## Known gap
- `GlobalStates.qml` (the pulse patch) is NOT in the surface tree or `deploy.sh` manifest — only hand-patched live on the phone. A clean deploy ships a dock whose swipe/pulse IPC no-ops. Adopt it into the tree + manifest.

View file

@ -18,11 +18,13 @@ II="${QS}/ii"
# Files replacing an upstream ii file get a one-time .upstream backup;
# files with no upstream counterpart (new components) are just linked.
MANIFEST="
GlobalStates.qml ii/GlobalStates.qml
services/Souveraine.qml ii/services/Souveraine.qml
services/Ai.qml ii/services/Ai.qml
services/TaskbarApps.qml ii/services/TaskbarApps.qml
services/GlobalFocusGrab.qml ii/services/GlobalFocusGrab.qml
services/Idle.qml ii/services/Idle.qml
services/ConflictKiller.qml ii/services/ConflictKiller.qml
modules/ii/sidebarLeft/SidebarLeft.qml ii/modules/ii/sidebarLeft/SidebarLeft.qml
modules/ii/sidebarLeft/AiChat.qml ii/modules/ii/sidebarLeft/AiChat.qml
modules/ii/sidebarRight/SidebarRight.qml ii/modules/ii/sidebarRight/SidebarRight.qml

View file

@ -314,6 +314,13 @@ Singleton {
property JsonObject conflictKiller: JsonObject {
property bool autoKillNotificationDaemons: false
// DO NOT auto-kill trays here. On this phone kded6 is not a
// competing Plasma tray it is the StatusNotifierWatcher that
// ii's OWN tray (Quickshell.Services.SystemTray) registers as a
// host with (IsStatusNotifierHostRegistered=true). Killing it
// just makes D-Bus re-activate it on ii's next tray call an
// infinite loop. Our services/ConflictKiller.qml override drops
// the kded6 check entirely. Leave this false.
property bool autoKillTrays: false
}

View file

@ -192,7 +192,18 @@ Scope { // Scope
WlrLayershell.layer: WlrLayer.Overlay
color: "transparent"
implicitHeight: (Config.options?.dock.height ?? 70) + Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut
// Content-driven: tall enough for one 64px dock button + the
// row's 8px bottom margin + the 32px pill strip, with Config
// dock.height as a floor. A fixed config height (72) left the
// visible bar ~36px for 64px buttons icons poked out the
// bottom and count dots landed under the bar. Size off the
// BUTTON's implicit height, NOT dockRow.implicitHeight: the
// separator's Layout margins inflate the row's implicit and
// made the bar overshoot (content then top-aligned with a dead
// band underneath).
implicitHeight: Math.max(Config.options?.dock.height ?? 70,
overviewButton.implicitHeight + 8 + 32)
+ Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut
mask: Region {
item: dockMouseArea
@ -257,16 +268,34 @@ Scope { // Scope
anchors.fill: dockVisualBackground
anchors.topMargin: 0
anchors.bottomMargin: 8
// The background is built 2*padding wider than the
// row (dockBackground.implicitWidth); inset the row
// so that width actually becomes side padding
// without it the first icon sat ON the rounded corner.
anchors.leftMargin: padding
anchors.rightMargin: padding
spacing: 3
property real padding: 5
DockApps {
id: dockApps
buttonPadding: dockRow.padding
// Keep the whole dock on-screen: the app list
// may take at most what's left after the fixed
// separator + overview button + paddings. Past
// that it scrolls horizontally.
maxWidth: dockRoot.width
- Appearance.sizes.elevationMargin * 2
- dockSeparator.implicitWidth
- overviewButton.implicitWidth
- dockRow.spacing * 2 - 5 * 2
onRequestDockShowChanged: root.previewShowing = requestDockShow
}
DockSeparator {}
DockSeparator {
id: dockSeparator
}
DockButton {
id: overviewButton
Layout.fillHeight: true
onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen
topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding

View file

@ -290,66 +290,74 @@ DockButton {
contentItem: Loader {
active: !isSeparator
// The Loader stretches its loaded item to the Loader's own size, so
// width/height/centerIn declared ON the loaded item are overridden
// that stretched the block to the content area and the top-anchored
// icon rode the top of the bar. The stretched item is a passthrough;
// the real fixed-size block centers INSIDE it (same structure as
// DockStack keep them identical).
sourceComponent: Item {
anchors.centerIn: parent
// Center the icon+dots block. Reserve only HALF the dot strip
// below the icon: reserving the full strip pushed the block's
// center below the icon's center, so centerIn left extra gap
// above the icon (icons read a few px high). Half-reserve splits
// the difference so the glyph sits visually centered.
width: root.iconSize
height: root.iconSize + (root.countDotHeight + 2) / 2
Item {
anchors.centerIn: parent
// Center the icon+dots block. Reserve only HALF the dot strip
// below the icon: reserving the full strip pushed the block's
// center below the icon's center, so centerIn left extra gap
// above the icon (icons read a few px high). Half-reserve splits
// the difference so the glyph sits visually centered.
width: root.iconSize
height: root.iconSize + (root.countDotHeight + 2) / 2
Loader {
id: iconImageLoader
anchors {
left: parent.left
right: parent.right
top: parent.top
}
height: root.iconSize
active: !root.isSeparator
sourceComponent: IconImage {
source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing")
implicitSize: root.iconSize
}
}
Loader {
active: Config.options.dock.monochromeIcons
anchors.fill: iconImageLoader
sourceComponent: Item {
Desaturate {
id: desaturatedIcon
visible: false // There's already color overlay
anchors.fill: parent
source: iconImageLoader
desaturation: 0.8
Loader {
id: iconImageLoader
anchors {
left: parent.left
right: parent.right
top: parent.top
}
ColorOverlay {
anchors.fill: desaturatedIcon
source: desaturatedIcon
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.9)
height: root.iconSize
active: !root.isSeparator
sourceComponent: IconImage {
source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing")
implicitSize: root.iconSize
}
}
}
RowLayout {
spacing: 3
anchors {
top: iconImageLoader.bottom
topMargin: 2
horizontalCenter: parent.horizontalCenter
Loader {
active: Config.options.dock.monochromeIcons
anchors.fill: iconImageLoader
sourceComponent: Item {
Desaturate {
id: desaturatedIcon
visible: false // There's already color overlay
anchors.fill: parent
source: iconImageLoader
desaturation: 0.8
}
ColorOverlay {
anchors.fill: desaturatedIcon
source: desaturatedIcon
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.9)
}
}
}
Repeater {
model: Math.min(appToplevel.toplevels.length, 3)
delegate: Rectangle {
required property int index
radius: Appearance.rounding.full
implicitWidth: (appToplevel.toplevels.length <= 3) ?
root.countDotWidth : root.countDotHeight // Circles when too many
implicitHeight: root.countDotHeight
color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
RowLayout {
spacing: 3
anchors {
top: iconImageLoader.bottom
topMargin: 2
horizontalCenter: parent.horizontalCenter
}
Repeater {
model: Math.min(appToplevel.toplevels.length, 3)
delegate: Rectangle {
required property int index
radius: Appearance.rounding.full
implicitWidth: (appToplevel.toplevels.length <= 3) ?
root.countDotWidth : root.countDotHeight // Circles when too many
implicitHeight: root.countDotHeight
color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
}
}
}
}

View file

@ -30,11 +30,19 @@ Item {
property string dragTargetAppId: ""
property string dragTargetStackId: ""
// Cap from Dock.qml: the widest the app list may grow before the dock
// would outgrow the screen. Past it the ListView scrolls horizontally.
property real maxWidth: -1
Layout.fillHeight: true
// No top-only margin: it shoved the whole icon list DOWN inside the bar
// with nothing balancing it the actual cause of icons drooping below
// the dock. fillHeight + the dockRow centering handle vertical placement.
implicitWidth: listView.implicitWidth
implicitWidth: maxWidth >= 0 ? Math.min(listView.contentWidth, maxWidth) : listView.contentWidth
Behavior on implicitWidth {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
function popupCenterXForButton(button) {
if (!button || !root.QsWindow)
@ -46,15 +54,12 @@ Item {
id: listView
spacing: 2
orientation: ListView.Horizontal
anchors {
top: parent.top
bottom: parent.bottom
}
implicitWidth: contentWidth
Behavior on implicitWidth {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
anchors.fill: parent
clip: true
// Only flickable when the list actually overflows, so touch drags on
// the buttons (drag-to-combine) aren't stolen while everything fits.
interactive: contentWidth > width
boundsBehavior: Flickable.StopAtBounds
model: ScriptModel {
objectProp: "appId"

View file

@ -143,52 +143,65 @@ DockButton {
// grid of the member icons (up to 4), so it reads as "a group of these
// apps" at a glance instead of a mystery blob. Count dots underneath.
contentItem: Item {
// Same block pattern as DockAppButton: icon+dots block centered as a
// unit, reserving half the dot strip, so the stack rides the bar
// exactly like a plain app icon instead of being placed by its own
// rules.
Item {
id: collapsedStack
anchors.centerIn: parent
width: root.iconSize
height: root.iconSize
height: root.iconSize + (root.countDotHeight + 2) / 2
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.small
color: ColorUtils.transparentize(Appearance.m3colors.m3surfaceContainer, 0.15)
border.width: 1
border.color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.8)
}
Grid {
anchors.centerIn: parent
columns: 2
spacing: 3
Repeater {
model: Math.min(root.members.length, 4)
delegate: IconImage {
required property int index
implicitSize: (root.iconSize - 11) / 2
source: Quickshell.iconPath(AppSearch.guessIcon(root.members[index] ?? ""), "image-missing")
Item {
id: collapsedStack
anchors {
left: parent.left
right: parent.right
top: parent.top
}
height: root.iconSize
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.small
color: ColorUtils.transparentize(Appearance.m3colors.m3surfaceContainer, 0.15)
border.width: 1
border.color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.8)
}
Grid {
anchors.centerIn: parent
columns: 2
spacing: 3
Repeater {
model: Math.min(root.members.length, 4)
delegate: IconImage {
required property int index
implicitSize: (root.iconSize - 11) / 2
source: Quickshell.iconPath(AppSearch.guessIcon(root.members[index] ?? ""), "image-missing")
}
}
}
}
}
// Count dot(s) under the icon same pattern as DockAppButton.
RowLayout {
spacing: 3
anchors {
top: collapsedStack.bottom
topMargin: 2
horizontalCenter: parent.horizontalCenter
}
Repeater {
model: Math.min(root.members.length, 3)
delegate: Rectangle {
required property int index
radius: Appearance.rounding.full
implicitWidth: (root.members.length <= 3) ? root.countDotWidth : root.countDotHeight
implicitHeight: root.countDotHeight
color: (root.appToplevel?.toplevels?.length ?? 0) > 0
? Appearance.colors.colPrimary
: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
// Count dot(s) under the icon same pattern as DockAppButton.
RowLayout {
spacing: 3
anchors {
top: collapsedStack.bottom
topMargin: 2
horizontalCenter: parent.horizontalCenter
}
Repeater {
model: Math.min(root.members.length, 3)
delegate: Rectangle {
required property int index
radius: Appearance.rounding.full
implicitWidth: (root.members.length <= 3) ? root.countDotWidth : root.countDotHeight
implicitHeight: root.countDotHeight
color: (root.appToplevel?.toplevels?.length ?? 0) > 0
? Appearance.colors.colPrimary
: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
}
}
}
}

View file

@ -0,0 +1,47 @@
// Souveraine patch to ii's stock ConflictKiller.qml.
//
// Stock ii flags kded6 as a "conflicting tray". On this phone kded6 is not
// a competing Plasma tray it is the StatusNotifierWatcher that ii's OWN
// tray (Quickshell.Services.SystemTray) registers as a host with. Killing
// it just makes D-Bus re-activate it on ii's next tray call, and leaving
// autoKillTrays off pops the kill dialog on every shell start. So: drop
// the kded6 check entirely; keep the notification-daemon check unchanged.
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string killDialogQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("killDialog.qml"))
function load() {
// dummy to force init
}
Connections {
target: Config
function onReadyChanged() {
if (Config.ready) checkConflictsProc.running = true
}
}
Process {
id: checkConflictsProc
command: ["bash", "-c", `pidof mako dunst`]
stdout: StdioCollector {
onStreamFinished: {
const conflictingNotifications = this.text.trim().length > 0;
if (!conflictingNotifications) return;
if (Config.options.conflictKiller.autoKillNotificationDaemons)
Quickshell.execDetached(["killall", "mako", "dunst"])
else
Quickshell.execDetached(["qs", "-p", root.killDialogQmlPath])
}
}
}
}