feat: settings button in quick toggles now animate from util panel

This commit is contained in:
2026-11-04 11:39:02 +01:00
parent 79d74d1966
commit 1f8f1ee556
9 changed files with 366 additions and 25 deletions
-2
View File
@@ -10,7 +10,6 @@ CustomFlickable {
property real bottomFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
property real fadeAmount: 0.1
property real topFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
readonly property real dpr: Math.ceil((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1)
function fadeShouldBeActive(isStart: bool): bool {
// When content is smaller than flickable size, hide fade when rebound starts
@@ -30,7 +29,6 @@ CustomFlickable {
type: Anim.SlowEffects
}
}
layer.textureSize: Qt.size(width * dpr, height * dpr)
layer.effect: Mask {
maskSource: mask
+2 -1
View File
@@ -6,11 +6,12 @@ import qs.Effects
CustomListView {
id: root
readonly property bool horizontal: orientation === ListView.Horizontal
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
property bool fadeEnabled: true
property real fadeAmount: 0.1
property real fadeThreshold: 0.0
readonly property bool horizontal: orientation === ListView.Horizontal
property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
function contentSize(): real {
+1 -2
View File
@@ -145,9 +145,8 @@ Item {
Utils.Wrapper {
id: utilities
anchors.bottom: parent.bottom
anchors.right: parent.right
screenState: root.screenState
screen: root.screen
sidebar: sidebar
}
-1
View File
@@ -50,7 +50,6 @@ Region {
R {
height: panel.height * (1 - root.panels.utilities.offsetScale) + root.geometry.insetBottom(root.borderThickness)
panel: root.panels.utilities
y: root.win.height - height
}
R {
+1 -1
View File
@@ -266,7 +266,7 @@ CustomWindow {
deformAmount: 0.1
exclude: panels.sidebar.offsetScale > 0.08 ? [] : [sidebarBg]
panel: panels.utilities
topLeftRadius: 0
radius: panels.utilities.isDetached ? Tokens.rounding.largeIncreased + Tokens.padding.small : 0
}
PanelBg {
+6 -6
View File
@@ -2,16 +2,15 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Networking as QSNetwork
import ZShell.Components
import ZShell.Config
import qs.Modules.Sidebar.Utils
import qs.Components
import qs.Services
import qs.Helpers
import qs.Daemons
import qs.Modules.Settings
CustomRect {
id: root
@@ -38,16 +37,18 @@ CustomRect {
return true;
});
}
required property ScreenState screenState
readonly property int splitIndex: Math.ceil(quickToggles.length / 2)
required property PopoutState popouts
required property ScreenState screenState
color: Colors.tPalette.m3surfaceContainer
implicitHeight: layout.implicitHeight + Tokens.padding.extraLargeIncreased
radius: Tokens.rounding.small
SnapLayout {
orientation: Qt.Vertical
id: layout
orientation: Qt.Vertical
anchors.fill: parent
anchors.margins: Tokens.padding.large
@@ -167,8 +168,7 @@ orientation: Qt.Vertical
isToggle: false
onClicked: {
root.screenState.sidebar = false;
root.screenState.settings = true;
root.popouts.detachRequested("appearance");
}
}
}
+4
View File
@@ -10,7 +10,10 @@ import ZShell.Components
Item {
id: root
readonly property int padding: Tokens.padding.small
required property ScreenState screenState
required property PopoutState popouts
implicitHeight: ZUtils.snap(layout.implicitHeight, root)
implicitWidth: ZUtils.snap(layout.implicitWidth, root)
@@ -33,6 +36,7 @@ Item {
Toggles {
Layout.fillWidth: true
screenState: root.screenState
popouts: root.popouts
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import QtQuick
QtObject {
property string currentName
property string hasCurrent
signal detachRequested(mode: string)
}
+344 -12
View File
@@ -2,41 +2,373 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import ZShell.Config
import ZShell
import qs.Modules.Settings as Settings
import qs.Components
Item {
id: root
property real offsetScale: shouldBeActive ? 0 : 1
required property ScreenState screenState
readonly property bool shouldBeActive: screenState.sidebar && !sidebar.chatActive
required property ShellScreen screen
required property Item sidebar
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
implicitHeight: content.implicitHeight + Tokens.padding.small * 2
implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
property string queuedMode
property real offsetScale: shouldBeActive ? 0 : 1
readonly property string wantedState: {
if (queuedMode.length > 0)
return "detached";
if (screenState.sidebar && !sidebar.chatActive)
return "docked";
return "hidden";
}
readonly property bool isDetached: wantedState === "detached"
readonly property bool shouldBeActive: wantedState !== "hidden"
readonly property int dur: Tokens.anim.durations.expressiveDefaultSpatial
readonly property int slowDur: Tokens.anim.durations.expressiveSlowSpatial
// Sizes come from the specific Comp, never from "whichever child is current"
readonly property real settingsW: settings.implicitWidth + (settings.item?.padding ?? 0) * 2
readonly property real settingsH: settings.implicitHeight + (settings.item?.padding ?? 0) * 2
readonly property real contentW: content.implicitWidth + (content.item?.padding ?? 0) * 2
readonly property real contentH: content.implicitHeight + (content.item?.padding ?? 0) * 2
// Slot positions
readonly property real hiddenX: parent.width
readonly property real hiddenY: parent.height - Config.bar.border + Tokens.padding.small * 2
readonly property real dockedX: parent.width - sidebar.width
readonly property real dockedY: parent.height - contentH
readonly property real centerX: (parent.screen.width - settingsW) / 2
readonly property real centerY: (parent.screen.height - settingsH) / 2
opacity: 1 - offsetScale
visible: offsetScale < 1
clip: true
function detach(mode: string): void {
queuedMode = mode;
focus = true;
}
function close(): void {
queuedMode = "";
}
// ---- State selection --------------------------------------------------
// `state` is only ever assigned `wantedState`, never an intermediate value.
// The only thing sync() decides is WHEN, i.e. whether the Comp the target
// state takes its size from is Ready yet.
// Restarted whenever either Comp's status or size changes. While it is
// running, a Comp's implicit size is still settling and must not be captured.
Timer {
id: settle
interval: 12 // about two frames; raise it if the height is still read too early
onTriggered: root.sync()
}
function stable(comp: Comp): bool {
return comp.status === Loader.Ready && comp.implicitHeight > 0 && !settle.running;
}
function ready(): bool {
switch (wantedState) {
case "detached":
return stable(settings);
case "docked":
return stable(content);
default:
// Closing from detached passes through the docked slot, so
// content's final size is needed before the transition starts.
return state !== "detached" || stable(content);
}
}
function sync(): void {
if (state !== wantedState && ready())
state = wantedState;
}
onWantedStateChanged: sync()
Component.onCompleted: sync()
Connections {
target: settings
function onStatusChanged(): void {
settle.restart();
}
function onImplicitHeightChanged(): void {
settle.restart();
}
function onImplicitWidthChanged(): void {
settle.restart();
}
}
Connections {
target: content
function onStatusChanged(): void {
settle.restart();
}
function onImplicitHeightChanged(): void {
settle.restart();
}
function onImplicitWidthChanged(): void {
settle.restart();
}
}
// ---- States: every position and size property lives here ------------
states: [
State {
name: "hidden"
PropertyChanges {
root.x: root.hiddenX
root.y: root.hiddenY
root.implicitWidth: 0
root.implicitHeight: 0
}
},
State {
name: "docked"
PropertyChanges {
root.x: root.dockedX
root.y: root.dockedY
root.implicitWidth: root.contentW
root.implicitHeight: root.contentH
}
},
State {
name: "detached"
PropertyChanges {
root.x: root.centerX
root.y: root.centerY
root.implicitWidth: root.settingsW
root.implicitHeight: root.settingsH
}
}
]
// One explicit transition per state pair. No `reversible`, no wildcards.
transitions: [
Transition {
from: "hidden"
to: "docked"
Anim {
properties: "x,y,implicitWidth,implicitHeight"
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
},
Transition {
from: "docked"
to: "hidden"
Anim {
properties: "x,y,implicitWidth,implicitHeight"
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
},
Transition {
from: "docked"
to: "detached"
Anim {
properties: "x,y,implicitWidth,implicitHeight"
duration: root.slowDur
easing: Tokens.anim.expressiveSlowSpatial
}
},
Transition {
from: "hidden"
to: "detached"
Anim {
properties: "x,y,implicitWidth,implicitHeight"
duration: root.slowDur
easing: Tokens.anim.expressiveSlowSpatial
}
},
Transition {
from: "detached"
to: "docked"
Anim {
properties: "x,y,implicitWidth,implicitHeight"
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
},
// 1) x, y and size to the docked slot, 2) only y drops, 3) snap the rest while invisible
Transition {
from: "detached"
to: "hidden"
ParallelAnimation {
Anim {
target: root
property: "x"
to: root.dockedX
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
Anim {
target: root
property: "y"
to: root.dockedY
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
Anim {
target: root
property: "implicitWidth"
to: root.contentW
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
Anim {
target: root
property: "implicitHeight"
to: root.contentH
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
SequentialAnimation {
PauseAnimation {
duration: root.dur - 300
}
Anim {
target: root
property: "y"
from: root.dockedY
to: root.hiddenY
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
PropertyAction {
target: root
properties: "x,implicitWidth,implicitHeight"
}
}
}
}
]
// Fade only. Nothing that moves or sizes the item is a Behavior any more.
Behavior on offsetScale {
Anim {
duration: Tokens.anim.durations.expressiveDefaultSpatial
duration: root.dur
easing: Tokens.anim.expressiveDefaultSpatial
}
}
Loader {
PopoutState {
id: popoutState
onDetachRequested: mode => root.detach(mode)
}
HyprlandFocusGrab {
active: root.isDetached
windows: [QsWindow.window]
onCleared: root.close()
}
Binding {
when: root.isDetached
target: QsWindow.window
property: "WlrLayershell.keyboardFocus"
value: WlrKeyboardFocus.OnDemand
}
Comp {
id: settings
readonly property int padding: 0
shouldBeActive: root.isDetached
anchors.centerIn: parent
sourceComponent: Settings.Content {
sState.screen: root.screen
sState.animatingContainer: settings.opacity < 1
sState.currentPageIdx: ["appearance", "network", "bluetooth", "audio"].indexOf(root.queuedMode)
onClose: root.close()
}
}
Comp {
id: content
active: root.shouldBeActive || root.visible
anchors.left: parent.left
anchors.margins: 8
anchors.top: parent.top
shouldBeActive: (root.shouldBeActive || root.visible) && !root.isDetached
anchors.centerIn: parent
sourceComponent: Content {
implicitWidth: root.implicitWidth - 8 * 2
popouts: popoutState
implicitWidth: root.sidebar.width - Tokens.padding.small * 2
screenState: root.screenState
}
}
component Comp: Loader {
id: comp
property bool shouldBeActive
active: false
opacity: 0
// Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set
states: State {
name: "active"
when: comp.shouldBeActive
PropertyChanges {
comp.opacity: 1
comp.active: true
}
}
transitions: [
Transition {
from: ""
to: "active"
SequentialAnimation {
PropertyAction {
property: "active"
}
Anim {
type: Anim.DefaultEffects
property: "opacity"
}
}
},
Transition {
from: "active"
to: ""
SequentialAnimation {
Anim {
type: Anim.DefaultEffects
property: "opacity"
}
PropertyAction {
property: "active"
}
}
}
]
}
}