Watch
1
0
Fork
You've already forked souveraine
0

quickshell: fix drag-to-combine, rail rides the keyboard, overview gap-tap dismiss

The drag ghost was anchored (centerIn) — Qt won't move an anchored
drag.target, so no DropArea ever fired and stack creation died silently;
position it on press instead. Drag lifecycle now sets dockDragInProgress
(the DockManifest guard finally has a writer). Restored pulseDockReveal,
lost in a refactor while its osk.pulseDock caller survived.

Rail lifts atop squeekboard by measured layer height (hyprctl -j layers,
namespace osk); swipe down dismisses the keyboard first, then a visible
dock in any state (new dockSuppressed, cleared by swipe up). Overview:
taps in the grid's dead space dismiss it instead of being eaten.
DockConfig grew the real pins/stacks editor (unpin, rename, unstack,
dissolve).
This commit is contained in:
Fimeg 2026-07-16 12:04:08 -04:00
commit c96f95b9da
8 changed files with 293 additions and 11 deletions

View file

@ -50,9 +50,31 @@ Singleton {
// or secondary state.
property bool dockRevealed: false
// True while a dock icon drag is in flight. Set by DragManager, read by
// DockManifest's state checks so structural edits (pin/stack mutations)
// can't land mid-drag and rewrite dock.stacks out from under a commit.
// A rail swipe-down on a visible dock dismisses it in ANY state —
// including pinned and shown-on-empty-desktop, which dockRevealed alone
// can't reach (it only governs the fullscreen case). Swipe up clears it.
property bool dockSuppressed: false
// Transient dock reveal: shows the dock for a few seconds and restores
// whatever state ruled before. Callers: the `osk pulseDock` IPC (glance
// at the dock without closing the keyboard) and the agent's harness.
// Restored 2026-07-16 — the definition was lost in a refactor while its
// IPC caller in OnScreenKeyboard.qml survived, so pulseDock threw.
property bool dockRevealPulse: false
function pulseDockReveal() {
root.dockRevealPulse = true;
dockRevealPulseTimer.restart();
}
Timer {
id: dockRevealPulseTimer
interval: 3000
onTriggered: root.dockRevealPulse = false
}
// True while a dock icon drag is in flight. Set by DockAppButton's drag
// lifecycle, read by DockManifest's state checks so structural edits
// (pin/stack mutations) can't land mid-drag and rewrite dock.stacks out
// from under a commit.
property bool dockDragInProgress: false
function superPressDuration() {

View file

@ -165,6 +165,7 @@ QtObject {
sidebarLeftOpen: GlobalStates.sidebarLeftOpen,
sidebarRightOpen: GlobalStates.sidebarRightOpen,
dockRevealed: GlobalStates.dockRevealed,
dockSuppressed: GlobalStates.dockSuppressed,
dockDragInProgress: GlobalStates.dockDragInProgress,
overlayOpen: GlobalStates.overlayOpen
};

View file

@ -65,7 +65,15 @@ Scope { // Scope
function computeDockState() {
if (root.activeMonitorHasFullscreen)
return GlobalStates.dockRevealed ? Dock.DockState.Shown : Dock.DockState.Hidden;
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)
@ -89,14 +97,17 @@ Scope { // Scope
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;
}

View file

@ -1,3 +1,4 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
@ -38,6 +39,9 @@ DockButton {
// reads which DropArea the drag ended over (appListRoot.dragTargetAppId,
// set by the DropArea's onEntered) and commits. NOT Qt's DropArea.onDropped
// — the anchored-proxy version never moved, so no DropArea ever saw it.
// The ghost therefore carries NO anchors: Qt refuses to move an anchored
// drag.target, and an unmoving ghost generates no enter events — the
// exact bug the line above describes. Position is set on press instead.
// GNOME's dwell rule (500ms before a hover counts as "combine") gates the
// highlight so a quick brush-past doesn't read as an intended stack.
property bool formingStack: false // this icon is the current drop target, dwell fired
@ -48,7 +52,6 @@ DockButton {
id: dragGhost
width: root.iconSize
height: root.iconSize
anchors.centerIn: parent
visible: dragMouse.dragging
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
@ -78,11 +81,21 @@ DockButton {
drag.target: dragGhost
drag.threshold: 12
pressAndHoldInterval: 450
onPressed: (mouse) => {
// Start the (not yet visible) ghost centered under the finger.
// drag.target then moves it 1:1 with the pointer, so its center
// hotspot tracks the finger across sibling DropAreas.
dragGhost.x = mouse.x - dragGhost.width / 2
dragGhost.y = mouse.y - dragGhost.height / 2
}
drag.onActiveChanged: {
if (drag.active) {
dragging = true
dragGhost.Drag.source = root
dragGhost.Drag.active = true
// Structural dock edits (manifest pin/stack mutations) are
// refused while a drag is in flight — see DockManifest.
GlobalStates.dockDragInProgress = true
// Hold-then-move means drag, not menu: a menu that opened on
// the hold gets dismissed the moment real movement starts.
root.menuOpen = false
@ -97,6 +110,7 @@ DockButton {
const targetStack = appListRoot.dragTargetStackId
dragGhost.Drag.active = false
dragging = false
GlobalStates.dockDragInProgress = false
if (target && target.toLowerCase() !== root.appToplevel.appId.toLowerCase()) {
TaskbarApps.combineIntoStack(target, root.appToplevel.appId, targetStack)
}
@ -111,7 +125,11 @@ DockButton {
root.activate()
}
}
onCanceled: { dragging = false; dragGhost.Drag.active = false }
onCanceled: {
dragging = false
dragGhost.Drag.active = false
GlobalStates.dockDragInProgress = false
}
}
DropArea {

View file

@ -115,6 +115,24 @@ Scope {
searchWidget.focusFirstItem();
}
// 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 —
// 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
// every real control (workspaces, windows, search) wins the tap.
// While the OSK is up this is the ONLY outside-tap dismiss — the
// focus-grab route is deliberately disabled then (see header).
MouseArea {
enabled: GlobalStates.overviewOpen && overviewLoader.active
x: columnLayout.x + overviewLoader.x
y: columnLayout.y + overviewLoader.y
width: overviewLoader.width
height: overviewLoader.height
onClicked: GlobalStates.overviewOpen = false
}
Column {
id: columnLayout
visible: GlobalStates.overviewOpen

View file

@ -1,5 +1,7 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import qs.services
import qs.modules.common
import qs.modules.common.widgets
@ -106,15 +108,156 @@ ContentPage {
}
ContentSection {
icon: "stacks"
title: Translation.tr("Pins & stacks")
icon: "push_pin"
title: Translation.tr("Pinned apps")
StyledText {
Layout.fillWidth: true
text: Translation.tr("Pinned apps and fan-out stacks are managed by drag on the dock itself (hold to combine, drag out to split). A list editor with rename lands here.")
text: Translation.tr("Drag on the dock is the primary way to manage these (drag onto an icon to stack, drag a member off the arc to split). This list is the fallback editor.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
// One row per pinned app. Model binds the raw config list so writes
// (from here, the dock, or the agent) re-render immediately.
Repeater {
model: Config.options.dock.pinnedApps
delegate: RowLayout {
required property string modelData
Layout.fillWidth: true
spacing: 8
IconImage {
implicitSize: 24
source: Quickshell.iconPath(AppSearch.guessIcon(modelData), "image-missing")
}
StyledText {
Layout.fillWidth: true
text: modelData
elide: Text.ElideMiddle
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.small
}
RippleButton {
implicitWidth: 32
implicitHeight: 32
onClicked: TaskbarApps.togglePin(modelData)
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "close"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSurface
}
StyledToolTip { text: Translation.tr("Unpin") }
}
}
}
StyledText {
visible: (Config.options.dock.pinnedApps?.length ?? 0) === 0
text: Translation.tr("Nothing pinned. Long-press a running app on the dock to pin it.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
ContentSection {
icon: "stacks"
title: Translation.tr("Stacks")
// One block per stack: editable name, then member rows. All edits go
// through TaskbarApps so the rules (id never changes, empty stacks
// dissolve, unstacked members re-pin) live in one place.
Repeater {
model: Config.options.dock.stacks
delegate: ColumnLayout {
id: stackBlock
required property string modelData
readonly property var stack: TaskbarApps.parseStack(modelData)
Layout.fillWidth: true
spacing: 2
RowLayout {
Layout.fillWidth: true
spacing: 8
MaterialSymbol {
text: "stacks"
iconSize: Appearance.font.pixelSize.large
color: Appearance.m3colors.m3onSurface
}
MaterialTextField {
Layout.fillWidth: true
text: stackBlock.stack.name
placeholderText: Translation.tr("Stack name")
onEditingFinished: {
if (text.length > 0 && text !== stackBlock.stack.name)
TaskbarApps.renameStack(stackBlock.stack.id, text);
}
}
RippleButton {
implicitWidth: 32
implicitHeight: 32
onClicked: {
// Dissolve: every member goes back to a pin.
for (const m of stackBlock.stack.members.slice())
TaskbarApps.unstackMember(stackBlock.stack.id, m);
}
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "delete"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSurface
}
StyledToolTip { text: Translation.tr("Dissolve stack (members become pins)") }
}
}
Repeater {
model: stackBlock.stack.members
delegate: RowLayout {
required property string modelData
Layout.fillWidth: true
Layout.leftMargin: 28
spacing: 8
IconImage {
implicitSize: 22
source: Quickshell.iconPath(AppSearch.guessIcon(modelData), "image-missing")
}
StyledText {
Layout.fillWidth: true
text: modelData
elide: Text.ElideMiddle
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.small
}
RippleButton {
implicitWidth: 32
implicitHeight: 32
onClicked: TaskbarApps.unstackMember(stackBlock.stack.id, modelData)
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "remove"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSurface
}
StyledToolTip { text: Translation.tr("Unstack (back to a pin)") }
}
}
}
}
}
StyledText {
visible: (Config.options.dock.stacks?.length ?? 0) === 0
text: Translation.tr("No stacks yet. Drag one dock icon onto another and hold until it highlights.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
}

View file

@ -33,7 +33,7 @@ ContentPage {
StyledText {
Layout.fillWidth: true
text: Translation.tr("Double-tap toggles app fullscreen; swipe up reveals the dock; swipe down hides it. Timing and threshold controls will appear here once their defaults prove out.")
text: Translation.tr("Double-tap toggles app fullscreen. Swipe up reveals the dock. Swipe down dismisses the nearest surface: the keyboard when it's open (the rail rides on top of it), otherwise a visible dock — pinned included. Timing and threshold controls will appear here once their defaults prove out.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap

View file

@ -4,8 +4,15 @@
// second `qs -c …` configuration. It owns the small bottom input region that
// stays available over fullscreen applications: swipe up/down controls the
// dock and a double tap toggles fullscreen for the real active toplevel.
//
// When the on-screen keyboard is summoned the rail rides ATOP it (bottom
// margin = the keyboard's measured layer height, probed from
// `hyprctl -j layers`, namespace "osk"), and swipe down dismisses the
// keyboard first; with no keyboard up, swipe down dismisses a visible dock
// in any state (GlobalStates.dockSuppressed — pinned included).
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell.Wayland
import qs
@ -17,12 +24,67 @@ PanelWindow {
anchors.bottom: true
implicitWidth: 200
implicitHeight: Config.options?.dock.gestureRailHeight ?? 32
margins.bottom: 0
// Last measured squeekboard height; the probe below corrects it every
// time the keyboard opens (rotation changes it). 315 = portrait height
// on the Pixel's 540x1080 logical screen, the value measured live —
// only a first-boot fallback until the first probe lands.
property int oskLift: 315
margins.bottom: GlobalStates.oskOpen ? rail.oskLift : 0
exclusionMode: ExclusionMode.Ignore
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "souveraine:navigation-rail"
// Measure the keyboard's real layer-surface height. squeekboard maps up
// to ~1s after SetVisible on a cold start (OnScreenKeyboard.qml sleeps 1
// before the D-Bus call), so a miss retries once.
property int oskProbeAttempts: 0
Connections {
target: GlobalStates
function onOskOpenChanged() {
if (GlobalStates.oskOpen) {
rail.oskProbeAttempts = 0;
oskProbeRetry.stop();
oskProbe.running = true;
}
}
}
Process {
id: oskProbe
command: ["hyprctl", "-j", "layers"]
stdout: StdioCollector {
onStreamFinished: {
try {
const monitors = JSON.parse(text);
for (const mon of Object.values(monitors)) {
for (const surfaces of Object.values(mon.levels)) {
for (const s of surfaces) {
if (s.namespace === "osk" && s.h > 0) {
rail.oskLift = s.h;
return;
}
}
}
}
} catch (e) {
console.log("[rail] osk layer probe parse failed:", e);
}
// Keyboard not mapped yet (cold start takes ~1s). Retry a
// couple of times, then keep the last known height.
if (GlobalStates.oskOpen && rail.oskProbeAttempts < 2)
oskProbeRetry.restart();
}
}
}
Timer {
id: oskProbeRetry
interval: 1200
onTriggered: {
rail.oskProbeAttempts += 1;
if (GlobalStates.oskOpen) oskProbe.running = true;
}
}
Rectangle {
id: handle
anchors.centerIn: parent
@ -65,12 +127,19 @@ PanelWindow {
if (deltaY <= -swipeDistance) {
lastTapAt = -1
GlobalStates.dockSuppressed = false
GlobalStates.dockRevealed = true
return
}
if (deltaY >= swipeDistance) {
lastTapAt = -1
// Nearest surface first: keyboard, then a visible dock.
if (GlobalStates.oskOpen) {
GlobalStates.oskOpen = false
return
}
GlobalStates.dockRevealed = false
GlobalStates.dockSuppressed = true
return
}
if (Math.abs(deltaY) > tapSlop) {