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:
parent
8e540c773d
commit
c96f95b9da
8 changed files with 293 additions and 11 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ QtObject {
|
|||
sidebarLeftOpen: GlobalStates.sidebarLeftOpen,
|
||||
sidebarRightOpen: GlobalStates.sidebarRightOpen,
|
||||
dockRevealed: GlobalStates.dockRevealed,
|
||||
dockSuppressed: GlobalStates.dockSuppressed,
|
||||
dockDragInProgress: GlobalStates.dockDragInProgress,
|
||||
overlayOpen: GlobalStates.overlayOpen
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue