Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/ii/dock/Dock.qml

501 lines
25 KiB
QML

// Pixel3Arch patch to ii's stock Dock.qml (2026-07-07).
//
// Problem: the dock (pinned, exclusiveZone claiming real screen space) and
// wvkbd (the on-screen keyboard, see OnScreenKeyboard.qml) both anchor to
// the bottom edge and both claim exclusive zones — their reservations
// stack instead of one giving way, so the keyboard/its focus-grab shield
// drift out of alignment with each other depending on what else is
// reserving space. Simplest real fix: the dock gets out of the way
// entirely while the keyboard is open, instead of trying to make the
// keyboard-side math account for wherever the dock happens to be.
//
// GlobalStates.oskOpen suppresses both reveal-when-idle and the pinned
// exclusive zone. In fullscreen Souveraine's navigation rail explicitly
// reveals or hides the dock; it never times out or opens another surface.
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import "." as DockLocal
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Hyprland
Scope { // Scope
id: root
property bool pinned: Config.options?.dock.pinnedOnStartup ?? false
// Dock visibility as one explicit state instead of overlapping booleans.
// HIDDEN - fully tucked below the edge
// SHOWN - visible without claiming exclusive space
// PINNED - visible AND reserving an exclusive zone
// OSK-open and previewPopup-hover are *inputs* to this, not states.
enum DockState { Hidden, Shown, Pinned }
// The normal dock pin is suppressed while the OSK is open. Fullscreen
// always takes precedence, so fullscreen remains genuinely edge-to-edge
// until the navigation rail explicitly reveals the dock.
property bool effectivePinned: root.pinned && !GlobalStates.oskOpen
// The dock's state projection + guarded mutation surface for external
// callers (the agent via Souveraine's harness). Lives here, not as a
// qs.services singleton, to avoid a circular import with GlobalStates.
DockLocal.DockManifest { id: dockManifest }
// requestDockShow (previewPopup hover) is threaded up from DockApps via
// this alias so the state computation can see it in one place.
property bool previewShowing: false
// App mode: any fullscreen window on the focused monitor owns the
// display, so the dock hides. Two previous checks both had blind spots:
// (1) ws.toplevels scan via ext-foreign-toplevel-list used
// wayland?.fullscreen which is unreliable, and never saw
// standalone qs -p windows (souveraine-settings).
// (2) HyprlandData.activeWindow?.fullscreen === 2 only saw the
// focused window — missed fullscreen apps that lost focus to a
// layer-shell surface (the dock itself, notifications, OSK).
// HyprlandData.windowList (hyprctl clients -j) has ALL windows with
// their real fullscreen mode. Scan it for any fullscreen === 2 window
// on the focused monitor. Reactive: HyprlandData updates on every
// Hyprland event.
readonly property bool activeMonitorHasFullscreen: {
const focusedId = HyprlandData.monitors.find(m => m.focused)?.id;
if (focusedId === undefined) return false;
return HyprlandData.windowList.some(w => w.fullscreen === 2 && w.monitor === focusedId);
}
function computeDockState() {
// Multitasking owns the screen. The dock is *home's* furniture, and
// home is one of the cards — a dock over the strip is one destination's
// furniture drawn on top of the picker for all of them. Casey,
// 2026-08-16: *"remove the dock it should not be there."*
//
// First in the ladder, above the home check, because the gesture is
// most often made **from** home: `activeZone` is still 0 for the whole
// time the overview is up, so every rung below this returns PINNED.
//
// The peek counts. The destination draws from the first climbing pixel
// (`SystemGestureRail.peekWanted`), so the dock leaving as the cards
// arrive is one motion rather than two events.
if (GlobalStates.missionControlOpen || GlobalStates.overviewOpen
|| GlobalStates.missionPeek)
return Dock.DockState.Hidden;
// USB Hands owns the bottom of home while its trackpad is actually
// open. Arming the wire alone changes no furniture; opening the
// conditional controller makes the dock yield until it closes.
if (HidController.active)
return Dock.DockState.Hidden;
// A visible keyboard owns the bottom edge. This must precede the home
// zone's unconditional PINNED return below; otherwise home stacks the
// dock's exclusive zone under the OSK and charges the display twice.
// The deliberate pulse remains reachable, but is SHOWN rather than
// PINNED so it does not reserve another strip of screen.
if (GlobalStates.oskOpen)
return GlobalStates.dockRevealPulse
? Dock.DockState.Shown : Dock.DockState.Hidden;
// Zone one is home, and home has a dock. Casey, 2026-08-05: *"The dock
// should just always be on zone one."*
//
// Checked before everything below because on home it is not a reveal,
// a pulse, or a consequence of nothing being focused — it is furniture
// that is simply there, the way the widget space around it is. The
// whole ladder underneath decides when to show a dock that is normally
// absent; on home there is nothing to decide.
//
// The compositor is the authority on which zone is active — the shell
// asking `{"op":"workspaces"}` and believing its own copy is the
// second-decider shape — so this reads ViewtopControl's last answer and
// treats "unknown" as not-home rather than guessing.
if (ViewtopControl.activeZone === ViewtopControl.homeZone)
return Dock.DockState.Pinned;
// And off home it is gone — including when `pinned` is set, which is
// the config that used to win. Casey, 2026-08-05: *"when I tap on an
// app the dock shouldn't be there anymore."* The positive check above
// is not enough on its own: `effectivePinned` sits further down the
// ladder and returned Pinned on every zone, so opening an app left the
// dock exactly where it was.
//
// `-1` is "not asked yet" and deliberately falls through rather than
// hiding: a compositor that has not answered must not take the dock
// away, or a slow first reply reads as the dock being broken.
if (ViewtopControl.activeZone >= 0)
return Dock.DockState.Hidden;
if (root.activeMonitorHasFullscreen)
return (GlobalStates.dockRevealed || GlobalStates.dockRevealPulse)
? Dock.DockState.Shown : Dock.DockState.Hidden;
// An explicit pulse outranks suppression and the OSK — it exists to
// glance at the dock while the keyboard is up.
if (GlobalStates.dockRevealPulse)
return Dock.DockState.Shown;
// NOT gated on `oskOpen` here, and that is the point.
//
// Suppressing the dock whenever `oskOpen` was true — above
// `effectivePinned`, so it applied in every state — hid the dock
// PERMANENTLY on the device. `oskOpen` is not "the keyboard is on
// screen": GlobalStates' own comment says squeekboard hides itself
// whenever input-method focus drops and a hold re-asserts it, and the
// journal shows exactly that — self-showed / self-hid every couple of
// seconds, ending in `Visible=true` with no keyboard in front of the
// user. Gating a persistent surface on a flag that flaps turns a
// cosmetic overlap into a dock nobody can reach.
//
// The real signal is the keyboard's exclusive zone, which the
// compositor already applies: an unpinned dock declares zone 0 and is
// placed above the keyboard for free, the same mechanism that fixed the
// pill. What remains is the PINNED case, where the dock reserves space
// of its own and the two reservations stack. That wants fixing where
// the zones are arbitrated, not by reading a D-Bus property the
// keyboard flaps at us.
//
// Rail swipe-down dismissed a visible dock; swipe up brings it back.
if (GlobalStates.dockSuppressed)
return Dock.DockState.Hidden;
if (root.effectivePinned)
return Dock.DockState.Pinned;
if (root.previewShowing)
return Dock.DockState.Shown;
// Empty desktop (nothing focused) reveals the dock, unless the OSK took
// the bottom edge. Kept here, where it was: on an empty desktop a
// spuriously-true `oskOpen` costs a reveal that would have been
// cosmetic anyway, which is a very different price from hiding a pinned
// dock the user relies on.
if (!GlobalStates.oskOpen && !ToplevelManager.activeToplevel?.activated)
return Dock.DockState.Shown;
return Dock.DockState.Hidden;
}
property int dockState: computeDockState()
// The navigation rail's dock contract is intentionally just two operations:
// swipe up reveals the dock; swipe down hides it. No timer, no overview.
// The manifest + guarded pin/stack methods below extend the same target
// so the agent (via Souveraine's harness, not a new integration) reaches
// the dock through one IPC name. See services/DockManifest.qml and
// docs/tasks/souveraine-shell-ecosystem.md.
IpcHandler {
target: "dock"
function swipeUp(): void {
GlobalStates.dockSuppressed = false;
GlobalStates.dockRevealed = true;
}
function swipeDown(): void {
GlobalStates.dockRevealed = false;
GlobalStates.dockSuppressed = true;
}
function reveal(): void {
GlobalStates.dockSuppressed = false;
GlobalStates.dockRevealed = true;
}
// Toggle app-mode fullscreen on the REAL active window. The rail
// can't use a bare `hyprctl dispatch fullscreen` because tapping it
// makes the shell (org.quickshell) the focused surface, so hyprctl
// would fullscreen the rail, not the app. The mechanism is
// the one in the code below: Hyprland.activeToplevel stays the real
// app window across a layer-shell tap, and we dispatch AT its
// address. (An earlier draft went through ToplevelManager +
// ToplevelHandle::setFullscreen — that path is not what runs.)
function fullscreen(): void {
// Use Hyprland 0.55's named fullscreen API. Numeric modes select
// legacy/fake fullscreen behavior on this Lua dispatcher.
// Clear a revealed dock before either direction of the toggle.
GlobalStates.dockRevealed = false;
// Hyprland.activeToplevel is Hyprland's real active APP window and
// its .address is a stable window handle — a layer-shell rail tap
// never becomes a Hyprland toplevel, so this stays the app even
// after the tap focuses the shell. Dispatch AT that address so we
// fullscreen the app, not whatever hyprctl thinks is focused.
const raw = Hyprland.activeToplevel?.address;
if (!raw) return;
// .address may or may not carry the 0x prefix; normalize to exactly
// one. Selector "address:0x..." is verified working on this fork.
const addr = raw.startsWith("0x") ? raw : "0x" + raw;
Quickshell.execDetached(["hyprctl", "dispatch",
`hl.dsp.window.fullscreen({ window = "address:${addr}", mode = "fullscreen", action = "toggle" })`]);
}
// --- Manifest projection (read-only) + guarded mutation ----------
// These delegate to DockManifest, which owns the projection shape
// and the state checks. The agent calls `dock.manifest`, `dock.pin`,
// etc. — never parsing QML. Refusals return {ok:false, reason}, not
// errors, so a refused mutation is information the agent learns from.
//
// Returns are `string` (JSON), not `var`: quickshell marshals only
// string/int/bool/double/color across IPC and silently maps a `var`
// return to VOID (src/io/ipc.cpp ipcType()). Declared `: var`, these
// registered as `(): void` and returned nothing at all — the {ok,
// reason} contract never reached the caller. JSON-over-string is what
// actually crosses the socket.
function manifest(): string {
return JSON.stringify(dockManifest.manifest());
}
function pin(appId: string): string {
return JSON.stringify(dockManifest.pin(appId));
}
function unpin(appId: string): string {
return JSON.stringify(dockManifest.unpin(appId));
}
function addToStack(stackId: string, appId: string): string {
return JSON.stringify(dockManifest.addToStack(stackId, appId));
}
function removeFromStack(stackId: string, appId: string): string {
return JSON.stringify(dockManifest.removeFromStack(stackId, appId));
}
function renameStack(stackId: string, newName: string): string {
return JSON.stringify(dockManifest.renameStack(stackId, newName));
}
}
// Settings-surface stub (entry point b). souveraine-settings doesn't
// exist yet; the long-hold "App settings…" menu calls dockSettings.openApp
// here. For now it just pulses the dock and logs, so nothing errors and
// the future settings app has a stable IPC name to take over.
IpcHandler {
target: "dockSettings"
function openApp(appId: string): void {
console.log("[dockSettings] openApp stub for", appId,
"- souveraine-settings not yet installed");
GlobalStates.dockRevealed = true;
}
function open(): void {
console.log("[dockSettings] open stub - souveraine-settings not yet installed");
GlobalStates.dockRevealed = true;
}
}
// Shell layer/state registry — declarative surface model + read
// projection. Lives here (beside the dock, not as a qs.services
// singleton) for the same circular-import reason as DockManifest.
// See modules/common/ShellModel.qml and
// docs/tasks/souveraine-shell-ecosystem.md section 1.
ShellModel { id: shellModel }
IpcHandler {
target: "shell"
// JSON-over-string, not `var` — see the note on dock.manifest above.
// A `var` return marshals as VOID and silently drops the payload.
// surfaces() — registry list, one entry per meaningful surface with
// layer, gating state, config gate, and a live `active` flag.
function surfaces(): string {
return JSON.stringify(shellModel.surfaces());
}
// state() — the GlobalStates bits that matter for layer gating, plus
// the shell mode. Read-only snapshot.
function state(): string {
return JSON.stringify(shellModel.state());
}
}
Variants {
// For each monitor
model: Quickshell.screens
PanelWindow {
id: dockRoot
// Window
required property var modelData
screen: modelData
// The dock window maps only when it should paint. This is what
// actually hides it over a fullscreen app (a Hidden dockState
// unmounts the layer entirely) — the earlier `visible` was
// always-true and only `reveal` flipped, so the dock kept
// painting over fullscreen. hoverToReveal (mouse, off by default)
// keeps the strip alive for desktop pointer use.
property bool reveal: root.dockState !== Dock.DockState.Hidden
|| (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse)
// This space belongs to the always-on navigation rail. It is
// visually empty and must also be absent from the dock's *input*
// region; otherwise the dock receives touches before the rail.
readonly property int gestureRailHeight: Config.options?.dock.gestureRailHeight ?? 32
// The rail draws nothing on home — `SystemGestureRail` takes the
// handle's opacity to 0 there, which is Casey's own call from
// 2026-08-05: *"above the dock it has no function."* The dock went
// on reserving the strip anyway, so home had 32 px of gap under the
// bar holding space for a pill that was never going to appear.
//
// Only the *drawing* collapses. The input reservation above stays
// exactly where it is, because the rail still takes both swipes on
// home and a dock that claimed those pixels would eat the gesture.
readonly property int railVisualHeight:
ViewtopControl.activeZone === ViewtopControl.homeZone
? 0 : gestureRailHeight
visible: !GlobalStates.screenLocked && reveal
anchors {
bottom: true
left: true
right: true
}
exclusiveZone: root.dockState === Dock.DockState.Pinned ? implicitHeight - (Appearance.sizes.hyprlandGapsOut) - (Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut) : 0
implicitWidth: dockBackground.implicitWidth
WlrLayershell.namespace: "quickshell:dock"
// Overlay, not Top: fullscreen windows render above the Top
// layer, and the whole point of the edge swipe is to reach the
// dock from a fullscreen app.
WlrLayershell.layer: WlrLayer.Overlay
color: "transparent"
// Content-driven: tall enough for one 64px dock button + the
// row's 8px bottom margin + the navigation rail, 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 + gestureRailHeight)
+ Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut
mask: Region {
item: dockInputRegion
}
// Deliberately smaller than dockMouseArea. The full MouseArea is
// still useful for laying out and hovering dock content, while the
// layer-shell only advertises the bar itself as touchable.
Item {
id: dockInputRegion
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
width: dockMouseArea.width
height: Math.max(0, dockMouseArea.height - dockRoot.gestureRailHeight)
}
MouseArea {
id: dockMouseArea
height: parent.height
anchors {
top: parent.top
topMargin: dockRoot.reveal ? 0 : Config.options?.dock.hoverToReveal ? (dockRoot.implicitHeight - Config.options.dock.hoverRegionHeight) : (dockRoot.implicitHeight + 1)
horizontalCenter: parent.horizontalCenter
}
implicitWidth: dockHoverRegion.implicitWidth + Appearance.sizes.elevationMargin * 2
hoverEnabled: true
Behavior on anchors.topMargin {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Item {
id: dockHoverRegion
anchors.fill: parent
implicitWidth: dockBackground.implicitWidth
Item { // Wrapper for the dock background
id: dockBackground
anchors {
top: parent.top
// Reserve the rail: bottom-anchor short of the
// window's bottom so the dock never covers it.
bottom: parent.bottom
bottomMargin: dockRoot.railVisualHeight
horizontalCenter: parent.horizontalCenter
}
Behavior on anchors.bottomMargin {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
implicitWidth: dockRow.implicitWidth + 5 * 2
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut - dockRoot.railVisualHeight
StyledRectangularShadow {
target: dockVisualBackground
}
Rectangle { // The real rectangle that is visible
id: dockVisualBackground
property real margin: Appearance.sizes.elevationMargin
anchors.fill: parent
anchors.topMargin: Appearance.sizes.elevationMargin
anchors.bottomMargin: Appearance.sizes.hyprlandGapsOut
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.large
}
RowLayout {
id: dockRow
// Anchored to the visible bar. Split top/bottom
// so the icons ride UP inside the bar instead of
// drooping out the bottom (uniform margins left
// them low; less top + more bottom lifts them).
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 {
id: dockSeparator
}
DockButton {
id: overviewButton
Layout.fillHeight: true
onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen
topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding
bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding
contentItem: MaterialSymbol {
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
font.pixelSize: parent.width / 2
text: "apps"
color: Appearance.colors.colOnLayer0
}
}
}
}
}
}
}
}
}