Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/ii/dock/Dock.qml
Fimeg 9ccefd6c73 dock: fix fullscreen detection — scan all windows, not just active/focused
Previous fullscreen detection had two blind spots:
1. Workspace scan used wayland?.fullscreen (unreliable) and missed qs -p windows
2. HyprlandData.activeWindow fallback only saw the focused window — missed
   fullscreen apps that lost focus to layer-shell surfaces (dock, notifications)

Now scans HyprlandData.windowList (hyprctl clients -j) for any window with
fullscreen === 2 on the focused monitor. Source of truth for all windows,
not just the active one. Reactive via HyprlandData's event-driven updates.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 21:25:08 -04:00

404 lines
19 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() {
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;
// 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.
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
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.gestureRailHeight
horizontalCenter: parent.horizontalCenter
}
implicitWidth: dockRow.implicitWidth + 5 * 2
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut - dockRoot.gestureRailHeight
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
}
}
}
}
}
}
}
}
}