From 0a439d951e4a1308611e4099ca3a23df36e3caf1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 11 Aug 2026 00:22:59 +0200 Subject: [PATCH] updated style --- quickshell/README.md | 88 +++++++--- quickshell/bar/Bar.qml | 78 +++------ quickshell/bar/BarPill.qml | 9 + quickshell/bar/Dock.qml | 117 +++++++++++++ quickshell/bar/Rail.qml | 273 ++++++++++++++++++++++++++++++ quickshell/bar/RailGroup.qml | 92 ++++++++++ quickshell/bar/RailIcon.qml | 111 ++++++++++++ quickshell/bar/Resources.qml | 50 +++--- quickshell/bar/Tray.qml | 132 ++++++++------- quickshell/bar/Workspaces.qml | 51 +++++- quickshell/components/Geom.qml | 23 +++ quickshell/components/P5Panel.qml | 100 +++++++++-- quickshell/components/Popout.qml | 34 +++- quickshell/config/Theme.qml | 75 ++++++-- quickshell/overlays/PowerMenu.qml | 40 ++++- quickshell/shell.qml | 12 ++ 16 files changed, 1077 insertions(+), 208 deletions(-) create mode 100644 quickshell/bar/Dock.qml create mode 100644 quickshell/bar/Rail.qml create mode 100644 quickshell/bar/RailGroup.qml create mode 100644 quickshell/bar/RailIcon.qml diff --git a/quickshell/README.md b/quickshell/README.md index 716d71f..3902ab8 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -18,13 +18,38 @@ Hyprland starts it from `~/.config/hypr/modules/autostart.conf`. ## Layout +The shell is spread across three surfaces rather than crammed into one bar, +because on a 1080p panel horizontal space is cheap and vertical space is not: a +48px rail costs 2.5% of the width, where the same 48px along the bottom would +have cost 4.4% of the height on top of what the top bar already takes. + ``` -shell.qml entrypoint — one bar, power menu, toasts and OSD per screen -config/ palette, type scale, geometry, motion curves +┌────────────────────────────────────────────┐ +│ ◆ TAKEMI [1][2][3] window title 00:09 │ bar — 48px exclusive +├────────────────────────────────────────────┤ +│ ▐█▌│ rail — 48px exclusive +│ windows ▐ ▌│ +│ ◣ ▐█▌│ +│ ╱ ♪ TRACK ╲ ▁▃▅ 42% │ dock — no exclusive zone +└────────────────────────────────────────────┘ +``` + +- **bar** — identity, workspaces, focused window, clock. +- **rail** — network, bluetooth, audio, mic, backlight, battery, tray, + notifications, power. Popouts open leftward, into the screen. +- **dock** — now playing and the CPU/MEM/GPU readout. Reserves nothing: windows + tile the full height underneath, and the input mask is clipped to the cluster + so clicks elsewhere in the corner fall through. Idles near-invisible and wakes + on hover or on playback; the crimson corner wedge is the always-on handle that + marks where it lives. + +``` +shell.qml entrypoint — bar, rail, dock, power menu, toasts, OSD per screen +config/ palette, type scale, geometry, motion curves, shape variance components/ the P5 primitives: panels, gauges, sliders, split-ink text services/ data sources, all singletons -bar/ the bar and its modules -popouts/ hover panels hanging off bar modules +bar/ the three surfaces and their modules +popouts/ hover panels hanging off rail and bar modules overlays/ power menu, notification toasts, volume/brightness OSD lock/ session lock assets/ halftone and stripe tiles, generated with ImageMagick @@ -34,24 +59,24 @@ assets/ halftone and stripe tiles, generated with ImageMagick callers set implicit sizes from their own layout (`row.implicitWidth + contentPad * 2`), which keeps the shape maths out of binding loops. -## Bar +## Modules -| Module | Left click | Right click | Middle | Scroll | Hover | -|---|---|---|---|---|---| -| Sigil | app menu | reload shell | | | | -| Workspaces | switch | | | | | -| CPU/MEM/GPU | btop | | | | resource dashboard | -| Media | play/pause | next | raise player | next/prev | full player | -| Clock | pin calendar | | | | calendar | -| Tray | activate | app menu | secondary | | | -| Network | pin panel | toggle Wi-Fi | nm-connection-editor | | networks | -| Bluetooth | pin panel | toggle adapter | | | devices | -| Volume | mute | pin panel | pavucontrol | volume | audio mixer | -| Mic | mute | pin panel | | mic volume | audio mixer | -| Brightness | | | | brightness | | -| Battery | pin panel | | | | power detail | -| Bell | history + mark read | do not disturb | | | history | -| Power | power menu | | | | | +| Module | Where | Left click | Right click | Middle | Scroll | Hover | +|---|---|---|---|---|---|---| +| Sigil | bar | app menu | reload shell | | | | +| Workspaces | bar | switch | | | | | +| Clock | bar | pin calendar | | | | calendar | +| Network | rail | pin panel | toggle Wi-Fi | nm-connection-editor | | networks | +| Bluetooth | rail | pin panel | toggle adapter | | | devices | +| Volume | rail | mute | pin panel | pavucontrol | volume | audio mixer | +| Mic | rail | mute | pin panel | | mic volume | audio mixer | +| Brightness | rail | | | | brightness | | +| Battery | rail | pin panel | | | | power detail | +| Tray | rail | activate | app menu | secondary | | | +| Bell | rail | history + mark read | do not disturb | | | history | +| Power | rail | power menu | | | | | +| Media | dock | play/pause | next | raise player | next/prev | full player | +| CPU/MEM/GPU | dock | btop | | | | resource dashboard | Popouts open on a short dwell and stay while the pointer is over the trigger or the panel. Clicking pins one open. @@ -112,7 +137,20 @@ means restarting Hyprland from a TTY. `power-profiles-daemon` is not installed (TLP is), so the power-profile switching in `services/Battery.qml` stays dormant. Nothing else depends on it. -The display face is Adwaita Sans Black Italic, which is what was already on the -box. A heavy condensed grotesque — Anton, Archivo Black, Oswald — would land -closer to the real Persona 5 type. Change `Theme.fontDisplay` and everything -follows. +The display face is Archivo Black, set italic almost everywhere. Change +`Theme.fontDisplay` and everything follows. + +## Palette discipline + +Two hues, deliberately: teal carries every resting state, crimson means alarm +and nothing else. The old blue, green and amber remain in `Theme.qml` only as +aliases onto those two families so existing call sites keep working — don't +introduce new uses. `Theme.heat()` is a continuous teal→crimson ramp rather than +a hue cycle, so a number that has gone red means the same thing as a battery +that has gone red. Tray icons are force-flattened to a single colour for the +same reason: app brand colours wreck the palette faster than anything else. + +Geometry variance lives in the same file. `Theme.leanFor(i)`, `chopFor(i)` and +`cornersFor(i)` hand each module a different lean and chamfer by index, so +modules stop being congruent parallelograms in an even rhythm — pass a module's +index rather than accepting the defaults. diff --git a/quickshell/bar/Bar.qml b/quickshell/bar/Bar.qml index 6820955..46412de 100644 --- a/quickshell/bar/Bar.qml +++ b/quickshell/bar/Bar.qml @@ -7,10 +7,11 @@ import "root:/services" // One bar per monitor. // -// Layout is three clusters: identity and system state on the left, the clock -// and player dead centre, controls on the right. The centre is genuinely -// centred, so the left cluster's window title is capped at whatever space is -// actually left over — otherwise the clusters overlap on a 1080p panel. +// Identity, workspaces and the focused window on the left; the clock on the +// right. Status moved to the rail and the player and metrics moved to the dock, +// which is what buys the room for this to breathe — nothing here is fighting +// for horizontal space any more, so the title gets a generous cap and the type +// can run large instead of being squeezed to fit between two clusters. PanelWindow { id: root @@ -36,10 +37,6 @@ PanelWindow { exclusiveZone: Theme.barHeight + Theme.barMargin color: "transparent" - // Half the space not taken by the centre cluster, minus breathing room. - readonly property real sideBudget: - (width - centreCluster.width) / 2 - Theme.barGap * 2 - // ------------------------------------------------------------------ left Row { id: leftCluster @@ -55,15 +52,18 @@ PanelWindow { BarPill { id: spacesPill accent: Theme.primary - padding: Theme.padS + padding: Theme.padM interactive: false + lean: Theme.leanFor(1) + chop: Theme.chopFor(1) + chopCorners: Theme.cornersFor(1) implicitWidth: spacesRow.implicitWidth + contentPad * 2 Row { id: spacesRow anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - spacing: Theme.padM + spacing: Theme.padL Workspaces { id: spaces @@ -82,63 +82,27 @@ PanelWindow { anchors.verticalCenter: parent.verticalCenter screenRef: root.modelData - // Everything else on this side is fixed width, so the title - // gets whatever remains. - maxWidth: Math.max(70, root.sideBudget + // The only thing competing for this row is the clock, so the + // title can have most of the bar. + maxWidth: Math.max(120, root.width - sigil.implicitWidth - - resources.implicitWidth + - clock.implicitWidth - spaces.implicitWidth - - Theme.barGap * 2 - - Theme.padM * 3 - - spacesPill.leanInset) + - Theme.barGap * 3 + - Theme.padL * 2 + - Theme.padXL + - spacesPill.contentPad * 2) } } } - - Resources { - id: resources - screenRef: root.modelData - } - } - - // ---------------------------------------------------------------- centre - Row { - id: centreCluster - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.barGap - - MediaPill { - screenRef: root.modelData - } - - Clock { - screenRef: root.modelData - } } // ----------------------------------------------------------------- right - Row { - id: rightCluster + Clock { + id: clock anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - spacing: Theme.barGap - - Tray { - screenRef: root.modelData - } - - Status { - screenRef: root.modelData - } - - NotifButton { - screenRef: root.modelData - } - - PowerButton { - screenRef: root.modelData - } + screenRef: root.modelData } // A hairline of crimson under the whole bar — the Persona 5 underscore. diff --git a/quickshell/bar/BarPill.qml b/quickshell/bar/BarPill.qml index 92662bd..3b37bd7 100644 --- a/quickshell/bar/BarPill.qml +++ b/quickshell/bar/BarPill.qml @@ -11,10 +11,16 @@ Item { property bool active: false property bool slash: false property real lean: Theme.skew + property bool leanLeft: false property real padding: Theme.padM property bool interactive: true property bool halftone: false + // Chamfer, forwarded so modules can differ from one another instead of all + // sharing the panel default. + property real chop: Theme.cut + property var chopCorners: [1, 3] + readonly property bool hovered: hover.hovered readonly property real leanInset: panel.leanInset @@ -35,6 +41,9 @@ Item { id: panel anchors.fill: parent lean: root.lean + leanLeft: root.leanLeft + chop: root.chop + chopCorners: root.chopCorners padding: root.padding halftone: root.halftone halftoneColor: root.accent diff --git a/quickshell/bar/Dock.qml b/quickshell/bar/Dock.qml new file mode 100644 index 0000000..eea95bb --- /dev/null +++ b/quickshell/bar/Dock.qml @@ -0,0 +1,117 @@ +import QtQuick +import QtQuick.Shapes +import Quickshell +import Quickshell.Wayland +import "root:/config" +import "root:/components" +import "root:/services" + +// The floating bottom-left cluster: now playing, plus the CPU/MEM/GPU readout. +// +// This one reserves no space at all. Windows tile the full height of the screen +// underneath it, and the input mask is clipped to the cluster itself so clicks +// anywhere else in the corner fall straight through to whatever is below. +// +// At rest it sits at a quarter opacity — present enough to read at a glance, +// faint enough to ignore — and snaps to full weight when the pointer arrives. +PanelWindow { + id: root + + required property var modelData + screen: modelData + + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "takemi-dock" + + anchors { + bottom: true + left: true + } + + margins { + bottom: Theme.dockMargin + left: Theme.dockMargin + } + + implicitWidth: cluster.implicitWidth + implicitHeight: cluster.implicitHeight + + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + // Only the cluster takes pointer input; the rest of the surface is + // transparent to clicks. + mask: Region { item: cluster } + + readonly property bool awake: clusterHover.hovered || Media.playing + + // A crimson wedge pinned to the corner. This is the one part of the dock + // that never fades — it marks where the cluster lives so a nearly + // invisible idle state is still something you can aim at, and it doubles + // as the corner furniture the layout was missing. + Shape { + anchors.left: parent.left + anchors.bottom: parent.bottom + width: 26 + height: 26 + z: -1 + preferredRendererType: Shape.CurveRenderer + opacity: root.awake ? 0.0 : 0.9 + + Behavior on opacity { + NumberAnimation { duration: Theme.durBase } + } + + ShapePath { + fillColor: Theme.accent + strokeColor: "transparent" + PathPolyline { + path: [ + Qt.point(0, 26), + Qt.point(0, 6), + Qt.point(26, 26), + Qt.point(0, 26) + ] + } + } + } + + Row { + id: cluster + spacing: Theme.barGap + + opacity: root.awake ? 1.0 : Theme.dockIdleOpacity + + Behavior on opacity { + NumberAnimation { + duration: Theme.durSlow + easing.type: Easing.OutExpo + } + } + + // Lift off the corner as it wakes. + transform: Translate { + y: root.awake ? 0 : 4 + + Behavior on y { + NumberAnimation { + duration: Theme.durSlow + easing.type: Easing.OutBack + } + } + } + + HoverHandler { + id: clusterHover + } + + MediaPill { + screenRef: root.modelData + } + + Resources { + screenRef: root.modelData + } + } +} diff --git a/quickshell/bar/Rail.qml b/quickshell/bar/Rail.qml new file mode 100644 index 0000000..492838e --- /dev/null +++ b/quickshell/bar/Rail.qml @@ -0,0 +1,273 @@ +import QtQuick +import QtQuick.Shapes +import Quickshell +import Quickshell.Wayland +import "root:/config" +import "root:/components" +import "root:/services" +import "root:/popouts" + +// The right-hand status rail. +// +// Everything that used to be crammed into the right third of the top bar lives +// here instead: connectivity, audio, power, tray, notifications. On a 1080p +// panel this is the cheap edge — 48px of width is 2.5% of the screen, where the +// same 48px of height would have been 4.4% on top of what the top bar already +// takes. +// +// Slabs are grouped by what they do rather than spaced evenly, and each pulls a +// different lean and chamfer from the shape tables, so the column reads as a +// stack of distinct cards instead of a ladder. +PanelWindow { + id: root + + required property var modelData + screen: modelData + + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "takemi-rail" + + anchors { + top: true + bottom: true + right: true + } + + margins { + top: Theme.barHeight + Theme.barMargin * 2 + bottom: Theme.barMargin + right: Theme.barMargin + } + + implicitWidth: Theme.railWidth + exclusiveZone: Theme.railWidth + Theme.barMargin + color: "transparent" + + // Corner furniture. The dock pins a crimson wedge to the bottom-left; this + // is its opposite number, so the desktop is bracketed on the diagonal + // rather than merely edged. Decorative only — it sits under the slabs and + // takes no input. + Shape { + anchors.right: parent.right + anchors.bottom: parent.bottom + width: 30 + height: 30 + z: -1 + preferredRendererType: Shape.CurveRenderer + opacity: 0.9 + + ShapePath { + fillColor: Theme.accent + strokeColor: "transparent" + PathPolyline { + path: [ + Qt.point(30, 30), + Qt.point(30, 4), + Qt.point(2, 30), + Qt.point(30, 30) + ] + } + } + } + + // ------------------------------------------------------------------- top + Column { + id: upper + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.railGap + + // Connectivity. + RailGroup { + variant: 0 + accent: Sys.netUp ? Theme.primary : Theme.muted + implicitHeight: linkCol.implicitHeight + contentPad * 2 + + Column { + id: linkCol + anchors.centerIn: parent + spacing: Theme.padS + + RailIcon { + id: netIcon + icon: Net.icon + tint: Theme.primary + dim: !Sys.netUp + onClicked: netPopout.pinned = !netPopout.pinned + onRightClicked: Net.toggleWifi() + onMiddleClicked: Actions.openNetworkSettings() + } + + RailIcon { + id: btIcon + icon: Bt.icon + tint: Theme.primary + dim: !Bt.enabled + onClicked: btPopout.pinned = !btPopout.pinned + onRightClicked: Bt.toggle() + } + } + } + + // Audio and backlight. + RailGroup { + variant: 1 + accent: Audio.muted || Audio.micMuted ? Theme.accent : Theme.primary + implicitHeight: audioCol.implicitHeight + contentPad * 2 + + Column { + id: audioCol + anchors.centerIn: parent + spacing: Theme.padS + + RailIcon { + id: volIcon + icon: Audio.icon + readout: Audio.muted ? "" : Audio.volumePercent + "%" + alert: Audio.muted + onClicked: Audio.toggleMute() + onRightClicked: audioPopout.pinned = !audioPopout.pinned + onMiddleClicked: Actions.openAudioSettings() + onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05) + } + + RailIcon { + id: micIcon + icon: Audio.micIcon + alert: Audio.micMuted + onClicked: Audio.toggleMicMute() + onRightClicked: audioPopout.pinned = !audioPopout.pinned + onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05)) + } + + RailIcon { + id: brightIcon + visible: Brightness.available + icon: Brightness.icon + readout: Math.round(Brightness.percent) + "%" + tint: Theme.primary + onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5) + } + } + } + + // Battery. + RailGroup { + variant: 2 + visible: Battery.available + accent: Battery.critical ? Theme.accent : Theme.primary + implicitHeight: battIcon.implicitHeight + contentPad * 2 + + RailIcon { + id: battIcon + anchors.centerIn: parent + icon: Battery.icon + readout: Math.round(Battery.percent) + "%" + tint: Battery.tint + alert: Battery.critical + pulsing: Battery.critical + onClicked: battPopout.pinned = !battPopout.pinned + } + } + } + + // ---------------------------------------------------------------- bottom + Column { + id: lower + anchors.bottom: parent.bottom + + // Clear of the corner wedge, which would otherwise sit entirely behind + // the bottom slab and never be seen. + anchors.bottomMargin: 26 + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.railGap + + // Tray. + RailGroup { + variant: 3 + visible: tray.visible + accent: Theme.primary + implicitHeight: tray.implicitHeight + contentPad * 2 + + Tray { + id: tray + anchors.centerIn: parent + } + } + + // Notifications and power — the two that act on the session rather than + // report on it, so they sit apart at the foot of the rail. + RailGroup { + variant: 4 + accent: Notifs.dnd ? Theme.muted : Theme.primary + implicitHeight: actionCol.implicitHeight + contentPad * 2 + + Column { + id: actionCol + anchors.centerIn: parent + spacing: Theme.padS + + RailIcon { + id: bellIcon + icon: Notifs.icon + readout: Notifs.unread > 0 + ? (Notifs.unread > 9 ? "9+" : String(Notifs.unread)) + : "" + tint: Notifs.hasUnread ? Theme.accent : Theme.primary + dim: Notifs.dnd + alert: Notifs.hasUnread && !Notifs.dnd + onClicked: { + notifPopout.pinned = !notifPopout.pinned; + Notifs.markRead(); + } + onRightClicked: Notifs.toggleDnd() + } + + RailIcon { + id: powerIcon + icon: "󰐥" + tint: Actions.powerMenuOpen ? Theme.accent : Theme.subtext + onClicked: Actions.togglePowerMenu() + } + } + } + } + + // ------------------------------------------------------------- popouts + // All of these hang off the left edge of the rail, opening into the screen. + NetPopout { + id: netPopout + anchorItem: netIcon + triggerHovered: netIcon.hovered + fromEdge: Edges.Left + } + + BtPopout { + id: btPopout + anchorItem: btIcon + triggerHovered: btIcon.hovered + fromEdge: Edges.Left + } + + AudioPopout { + id: audioPopout + anchorItem: volIcon + triggerHovered: volIcon.hovered || micIcon.hovered + fromEdge: Edges.Left + } + + BatteryPopout { + id: battPopout + anchorItem: battIcon + triggerHovered: battIcon.hovered + fromEdge: Edges.Left + visible: Battery.available && shown + } + + NotifPopout { + id: notifPopout + anchorItem: bellIcon + triggerHovered: bellIcon.hovered + fromEdge: Edges.Left + } +} diff --git a/quickshell/bar/RailGroup.qml b/quickshell/bar/RailGroup.qml new file mode 100644 index 0000000..5d5687d --- /dev/null +++ b/quickshell/bar/RailGroup.qml @@ -0,0 +1,92 @@ +import QtQuick +import "root:/config" +import "root:/components" + +// A slab on the right-hand rail. The vertical counterpart to BarPill: it sizes +// itself to a stacked column rather than a row, and pulls its lean and chamfer +// from the shape-variance tables by `variant`, so no two slabs on the rail are +// congruent. +Item { + id: root + + property color accent: Theme.primary + property int variant: 0 + property bool active: false + property real padding: Theme.padS + property bool interactive: false + property bool halftone: false + + readonly property bool hovered: hover.hovered + readonly property real contentPad: panel.contentPad + readonly property alias panelItem: panel + + default property alias content: holder.data + + signal clicked() + + implicitWidth: Theme.railWidth + + P5Panel { + id: panel + anchors.fill: parent + vertical: true + + // Scaled down from the horizontal tables: a rail slab leans by a + // fraction of its 48px width, so the raw values would be far too steep. + lean: Theme.leanFor(root.variant) * 0.6 + leanLeft: Theme.leanLeftFor(root.variant) + chop: Math.min(Theme.chopFor(root.variant), 10) + chopCorners: Theme.cornersFor(root.variant) + + padding: root.padding + halftone: root.halftone + halftoneColor: root.accent + sheen: true + + fill: root.active ? Theme.surfaceAlt : Theme.surface + fillOpacity: root.hovered ? 0.98 : 0.88 + border: root.hovered || root.active + ? Theme.alpha(root.accent, 0.9) + : Theme.alpha(Theme.outline, 0.7) + borderWidth: root.hovered || root.active ? Theme.stroke : 1 + + slash: root.active + slashColor: root.accent + + Behavior on fillOpacity { + NumberAnimation { duration: Theme.durFast } + } + Behavior on border { + ColorAnimation { duration: Theme.durBase } + } + + Item { + id: holder + anchors.fill: parent + } + } + + // Slabs lift toward the pointer, away from the screen edge. + transform: Translate { + x: root.hovered && root.interactive ? -3 : 0 + + Behavior on x { + NumberAnimation { + duration: Theme.durBase + easing.type: Easing.OutBack + } + } + } + + HoverHandler { + id: hover + enabled: root.interactive + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.interactive + acceptedButtons: Qt.LeftButton + onTapped: root.clicked() + } +} diff --git a/quickshell/bar/RailIcon.qml b/quickshell/bar/RailIcon.qml new file mode 100644 index 0000000..859cc97 --- /dev/null +++ b/quickshell/bar/RailIcon.qml @@ -0,0 +1,111 @@ +import QtQuick +import "root:/config" +import "root:/components" + +// One slot on the right-hand rail: a glyph with an optional micro readout +// stacked beneath it, and a crimson bar that wipes down the leading edge on +// hover. The rail is only 48px wide, so unlike StatusIcon the readout sits +// under the glyph rather than beside it, and anything longer than a percentage +// belongs in the popout instead. +Item { + id: root + + property string icon: "" + property string readout: "" + property color tint: Theme.primary + property bool alert: false + property bool dim: false + property bool pulsing: false + + readonly property bool hovered: hover.hovered + + signal clicked() + signal rightClicked() + signal middleClicked() + signal scrolled(real delta) + + implicitWidth: Theme.railWidth + implicitHeight: root.readout !== "" ? 42 : 32 + + Column { + id: layout + anchors.centerIn: parent + spacing: 1 + + Icon { + id: glyph + anchors.horizontalCenter: parent.horizontalCenter + text: root.icon + pulsing: root.pulsing + font.pixelSize: Theme.fsLarge + color: { + if (root.alert) return Theme.accent; + if (root.dim) return Theme.muted; + if (root.hovered) return Theme.glow; + return root.tint; + } + + Behavior on color { + ColorAnimation { duration: Theme.durBase } + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + visible: root.readout !== "" + text: root.readout + color: root.dim ? Theme.muted : Theme.subtext + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.DemiBold + renderType: Text.NativeRendering + + Behavior on color { + ColorAnimation { duration: Theme.durBase } + } + } + } + + // Leading-edge wipe. On the rail this runs vertically down the left side, + // mirroring the slash P5Panel puts on horizontal modules. + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 2 + height: root.hovered ? layout.implicitHeight : 0 + color: root.alert ? Theme.accent : root.tint + + Behavior on height { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo } + } + } + + scale: root.hovered ? 1.08 : 1.0 + Behavior on scale { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } + } + + onAlertChanged: if (alert) glyph.bump() + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: root.clicked() + } + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: root.rightClicked() + } + TapHandler { + acceptedButtons: Qt.MiddleButton + onTapped: root.middleClicked() + } + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: event => root.scrolled(event.angleDelta.y) + } +} diff --git a/quickshell/bar/Resources.qml b/quickshell/bar/Resources.qml index 32253a2..d70b5f9 100644 --- a/quickshell/bar/Resources.qml +++ b/quickshell/bar/Resources.qml @@ -13,7 +13,14 @@ BarPill { required property var screenRef accent: Theme.heat(Math.max(Sys.cpuUsage, Sys.memPercent)) - padding: Theme.padS + + // Roomier than it was on the bar. In the dock there is nothing to compete + // with, so the figures get real padding instead of being pushed up against + // the slanted edges. + padding: Theme.padM + lean: Theme.leanFor(2) + chop: Theme.chopFor(2) + chopCorners: Theme.cornersFor(2) implicitWidth: layout.implicitWidth + contentPad * 2 onClicked: Actions.openSystemMonitor() @@ -59,6 +66,10 @@ BarPill { } } + // The CPU/MEM/GPU labels are gone: three characters of chrome per metric, + // repeated three times, in the most cramped part of the shell. The number + // over its meter is legible on its own, and dropping them buys the room to + // set the figure in display italic instead of squeezing it into mono. component Metric: Column { id: metric @@ -67,33 +78,20 @@ BarPill { property string readout: "" anchors.verticalCenter: parent.verticalCenter - spacing: 1 + spacing: 2 - Row { - spacing: 4 + Text { + text: metric.readout + color: Theme.heat(metric.value) + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsBody + font.weight: Theme.weightDisplay + font.italic: true + font.letterSpacing: 0.5 + renderType: Text.NativeRendering - Text { - anchors.verticalCenter: parent.verticalCenter - text: metric.label - color: Theme.muted - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - font.letterSpacing: 1.5 - renderType: Text.NativeRendering - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: metric.readout - color: Theme.heat(metric.value) - font.family: Theme.fontMono - font.pixelSize: Theme.fsSmall - font.weight: Font.DemiBold - renderType: Text.NativeRendering - - Behavior on color { - ColorAnimation { duration: Theme.durBase } - } + Behavior on color { + ColorAnimation { duration: Theme.durBase } } } diff --git a/quickshell/bar/Tray.qml b/quickshell/bar/Tray.qml index 17d2bff..badd95f 100644 --- a/quickshell/bar/Tray.qml +++ b/quickshell/bar/Tray.qml @@ -1,92 +1,102 @@ import QtQuick +import QtQuick.Effects import Quickshell import Quickshell.Services.SystemTray import Quickshell.Widgets import "root:/config" import "root:/components" -// StatusNotifier tray. Left click activates, right click opens the app's own -// menu, middle click is the secondary action. -BarPill { +// StatusNotifier tray, as a vertical column for the right-hand rail. Left click +// activates, right click opens the app's own menu, middle click is the +// secondary action. The surrounding slab is the caller's business — this is +// just the stack of icons. +Column { id: root - required property var screenRef - - accent: Theme.blue - padding: Theme.padS - interactive: false + spacing: Theme.padS + 2 visible: SystemTray.items.values.length > 0 - implicitWidth: visible ? layout.implicitWidth + contentPad * 2 : 0 - Row { - id: layout - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - spacing: Theme.padM + Repeater { + model: SystemTray.items - Repeater { - model: SystemTray.items + Item { + id: entry - Item { - id: entry + required property SystemTrayItem modelData - required property SystemTrayItem modelData + width: 18 + height: 18 + anchors.horizontalCenter: parent.horizontalCenter - width: 18 - height: 18 - anchors.verticalCenter: parent.verticalCenter + // App icons arrive in their own brand colours, which wrecks the + // two-hue palette faster than anything else in the shell. Flatten + // every one of them to a single-colour silhouette: white at rest, + // crimson under the pointer. Detail inside the glyph is lost on + // purpose — that flattening *is* the screen-printed look. + IconImage { + id: img + anchors.fill: parent + source: entry.modelData.icon + asynchronous: true - IconImage { - id: img - anchors.fill: parent - source: entry.modelData.icon - asynchronous: true - opacity: itemHover.hovered ? 1.0 : 0.82 + layer.enabled: true + layer.effect: MultiEffect { + colorization: 1.0 + colorizationColor: itemHover.hovered + ? Theme.accent + : Theme.text - Behavior on opacity { - NumberAnimation { duration: Theme.durFast } + Behavior on colorizationColor { + ColorAnimation { duration: Theme.durFast } } } - scale: itemHover.hovered ? 1.18 : 1.0 - Behavior on scale { - NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } - } + opacity: itemHover.hovered ? 1.0 : 0.82 - HoverHandler { - id: itemHover - cursorShape: Qt.PointingHandCursor + Behavior on opacity { + NumberAnimation { duration: Theme.durFast } } + } - TapHandler { - acceptedButtons: Qt.LeftButton - onTapped: { - if (entry.modelData.onlyMenu) { - menuAnchor.open(); - } else { - entry.modelData.activate(); - } + scale: itemHover.hovered ? 1.18 : 1.0 + Behavior on scale { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } + } + + HoverHandler { + id: itemHover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: { + if (entry.modelData.onlyMenu) { + menuAnchor.open(); + } else { + entry.modelData.activate(); } } + } - TapHandler { - acceptedButtons: Qt.RightButton - onTapped: menuAnchor.open() - } + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: menuAnchor.open() + } - TapHandler { - acceptedButtons: Qt.MiddleButton - onTapped: entry.modelData.secondaryActivate() - } + TapHandler { + acceptedButtons: Qt.MiddleButton + onTapped: entry.modelData.secondaryActivate() + } - QsMenuAnchor { - id: menuAnchor - menu: entry.modelData.menu - anchor.item: entry - anchor.edges: Edges.Bottom - anchor.gravity: Edges.Bottom - anchor.margins.top: Theme.popoutGap - } + // Menus open to the left, into the screen rather than off its edge. + QsMenuAnchor { + id: menuAnchor + menu: entry.modelData.menu + anchor.item: entry + anchor.edges: Edges.Left + anchor.gravity: Edges.Left + anchor.margins.right: Theme.popoutGap } } } diff --git a/quickshell/bar/Workspaces.qml b/quickshell/bar/Workspaces.qml index fb80ec6..a0ebf6a 100644 --- a/quickshell/bar/Workspaces.qml +++ b/quickshell/bar/Workspaces.qml @@ -40,12 +40,15 @@ Item { } implicitWidth: row.implicitWidth - implicitHeight: 20 + implicitHeight: 24 Row { id: row anchors.verticalCenter: parent.verticalCenter - spacing: 6 + + // Uneven gaps. An evenly spaced row of identical ticks reads as a + // progress bar; the whole point here is that it should not. + spacing: 5 Repeater { model: root.slots @@ -54,6 +57,7 @@ Item { id: pip required property var modelData + required property int index readonly property int wsId: modelData readonly property var info: root.occupancy[wsId] readonly property bool occupied: info !== undefined && info.count > 0 @@ -61,8 +65,19 @@ Item { readonly property bool focused: root.focusedId === wsId readonly property bool hovered: pipHover.hovered - width: focused ? 34 : (occupied ? 16 : 10) - height: 18 + // Each tick pulls its own shear and its own height off the + // variance tables, and sits a little off the baseline, so the + // row is a ragged run of marks rather than a repeating comb. + readonly property real shear: Theme.leanFor(pip.index) + readonly property int baseHeight: [18, 14, 20, 16][pip.index % 4] + readonly property int drift: [0, 2, -1, 1][pip.index % 4] + + width: focused ? 36 : (occupied ? 16 : 9) + + // The focused tick grows past its neighbours and past the row's + // own height, so it breaks the line instead of sitting in it. + height: focused ? 24 : pip.baseHeight + y: focused ? -2 : pip.drift Behavior on width { NumberAnimation { @@ -71,6 +86,30 @@ Item { easing.overshoot: 1.6 } } + Behavior on height { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } + } + Behavior on y { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } + } + + // Black offset copy behind the focused tick — the same + // screen-print stack the panels use, at tick scale. + Rectangle { + visible: pip.focused + anchors.fill: parent + x: 3 + y: 3 + color: Theme.ink + + transform: Matrix4x4 { + matrix: Qt.matrix4x4( + 1, -pip.shear, 0, pip.height * pip.shear, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1) + } + } Rectangle { id: tick @@ -85,7 +124,7 @@ Item { transform: Matrix4x4 { matrix: Qt.matrix4x4( - 1, -Theme.skew, 0, pip.height * Theme.skew, + 1, -pip.shear, 0, pip.height * pip.shear, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) @@ -104,7 +143,7 @@ Item { text: pip.wsId color: Theme.base font.family: Theme.fontDisplay - font.pixelSize: Theme.fsSmall + font.pixelSize: Theme.fsBody font.weight: Theme.weightDisplay font.italic: true renderType: Text.NativeRendering diff --git a/quickshell/components/Geom.qml b/quickshell/components/Geom.qml index 79ab20e..aacbd49 100644 --- a/quickshell/components/Geom.qml +++ b/quickshell/components/Geom.qml @@ -31,6 +31,29 @@ Singleton { ]; } + // Vertical counterpart: the *horizontal* edges slant instead, by a fraction + // of width. A tall rail slab has to lean this way — leaning its vertical + // edges by a fraction of its height would shear it clean off the screen. + function parallelogramV(w: real, h: real, lean: real): var { + const off = w * lean; + return [ + Qt.point(0, off), + Qt.point(w, 0), + Qt.point(w, h - off), + Qt.point(0, h) + ]; + } + + function parallelogramVL(w: real, h: real, lean: real): var { + const off = w * lean; + return [ + Qt.point(0, 0), + Qt.point(w, off), + Qt.point(w, h), + Qt.point(0, h - off) + ]; + } + function rect(w: real, h: real): var { return [Qt.point(0, 0), Qt.point(w, 0), Qt.point(w, h), Qt.point(0, h)]; } diff --git a/quickshell/components/P5Panel.qml b/quickshell/components/P5Panel.qml index bc119b0..b7753bc 100644 --- a/quickshell/components/P5Panel.qml +++ b/quickshell/components/P5Panel.qml @@ -18,6 +18,11 @@ Item { property real lean: Theme.skew property bool leanLeft: false + // Tall panels (the right-hand rail) slant their horizontal edges instead, + // by a fraction of width, and inset their content top/bottom rather than + // left/right. + property bool vertical: false + // Corner chop size, and which corners get chopped (indices into the // polygon, clockwise from the top-left). Default chops the two corners that // read as "cut" against the lean. @@ -37,10 +42,24 @@ Item { // Inner glow along the top edge, for panels that should feel backlit. property bool sheen: false - readonly property real leanInset: height * Math.abs(lean) - readonly property var polygon: leanLeft - ? Geom.parallelogramL(width, height, lean) - : Geom.parallelogram(width, height, lean) + // The screen-print stack. A pure-black copy of the silhouette sits behind + // the fill, offset down-and-right, and the fill carries a black keyline + // outside its coloured border. Together these are what stop the panel from + // reading as a flat clip-path. + property bool drop: true + property real dropOffset: Theme.dropOffset + property color dropColor: Theme.ink + property bool keyline: true + property real keylineWidth: Theme.keylineWidth + + readonly property real leanInset: + (vertical ? width : height) * Math.abs(lean) + + readonly property var polygon: vertical + ? (leanLeft ? Geom.parallelogramVL(width, height, lean) + : Geom.parallelogramV(width, height, lean)) + : (leanLeft ? Geom.parallelogramL(width, height, lean) + : Geom.parallelogram(width, height, lean)) // Child content lives inside `contentItem`, inset on both sides far enough // to clear the slanted edges. Panels do not auto-size: callers set implicit @@ -52,14 +71,57 @@ Item { readonly property real contentPad: padding + leanInset + // Offset black copy of the silhouette, behind everything. + Loader { + anchors.fill: parent + active: root.drop && root.dropOffset > 0 + sourceComponent: Shape { + preferredRendererType: Shape.CurveRenderer + asynchronous: false + + transform: Translate { + x: root.dropOffset * (root.leanLeft ? -1 : 1) + y: root.dropOffset + } + + ShapePath { + fillColor: root.dropColor + strokeColor: "transparent" + joinStyle: ShapePath.MiterJoin + + PathPolyline { + path: Geom.chamfer(root.polygon, root.chop, root.chopCorners) + } + } + } + } + Shape { id: body anchors.fill: parent preferredRendererType: Shape.CurveRenderer asynchronous: false + // Fill, carrying the black keyline. The stroke is centred on the path, + // so half of it sits outside the silhouette — which is exactly the + // heavy outer contour the style wants. ShapePath { fillColor: Theme.alpha(root.fill, root.fillOpacity) + strokeColor: root.keyline ? root.dropColor : "transparent" + strokeWidth: root.keyline + ? root.borderWidth + root.keylineWidth * 2 + : 0 + joinStyle: ShapePath.MiterJoin + capStyle: ShapePath.FlatCap + + PathPolyline { + path: Geom.chamfer(root.polygon, root.chop, root.chopCorners) + } + } + + // Coloured border, drawn on top of the keyline so it reads as an inlay. + ShapePath { + fillColor: "transparent" strokeColor: root.borderWidth > 0 ? root.border : "transparent" strokeWidth: root.borderWidth joinStyle: ShapePath.MiterJoin @@ -141,13 +203,25 @@ Item { strokeColor: "transparent" PathPolyline { path: { + const t = root.slashWidth; + + // On a rail slab the slash runs along the slanted top + // edge instead of down a side. + if (root.vertical) { + const voff = root.width * root.lean; + return root.leanLeft + ? Geom.closed([Qt.point(0, 0), Qt.point(root.width, voff), + Qt.point(root.width, voff + t), Qt.point(0, t)]) + : Geom.closed([Qt.point(0, voff), Qt.point(root.width, 0), + Qt.point(root.width, t), Qt.point(0, voff + t)]); + } + const off = root.height * root.lean; - const w = root.slashWidth; return root.leanLeft - ? Geom.closed([Qt.point(0, 0), Qt.point(w, 0), - Qt.point(off + w, root.height), Qt.point(off, root.height)]) - : Geom.closed([Qt.point(off, 0), Qt.point(off + w, 0), - Qt.point(w, root.height), Qt.point(0, root.height)]); + ? Geom.closed([Qt.point(0, 0), Qt.point(t, 0), + Qt.point(off + t, root.height), Qt.point(off, root.height)]) + : Geom.closed([Qt.point(off, 0), Qt.point(off + t, 0), + Qt.point(t, root.height), Qt.point(0, root.height)]); } } } @@ -157,9 +231,9 @@ Item { Item { id: contentHolder anchors.fill: parent - anchors.leftMargin: root.contentPad - anchors.rightMargin: root.contentPad - anchors.topMargin: root.padding - anchors.bottomMargin: root.padding + anchors.leftMargin: root.vertical ? root.padding : root.contentPad + anchors.rightMargin: root.vertical ? root.padding : root.contentPad + anchors.topMargin: root.vertical ? root.contentPad : root.padding + anchors.bottomMargin: root.vertical ? root.contentPad : root.padding } } diff --git a/quickshell/components/Popout.qml b/quickshell/components/Popout.qml index f920541..99c73e7 100644 --- a/quickshell/components/Popout.qml +++ b/quickshell/components/Popout.qml @@ -27,12 +27,25 @@ PopupWindow { signal dismissedFully() + // Panels hanging off the vertical rail open sideways, so the entry slide has + // to follow whichever axis the popout actually travels along, and start on + // the side nearest its trigger. + readonly property bool horizontal: + fromEdge === Edges.Left || fromEdge === Edges.Right + readonly property string slideProperty: horizontal ? "x" : "y" + readonly property real slideOrigin: bleed + (fromEdge === Edges.Left + || fromEdge === Edges.Top ? 14 : -14) + anchor.item: anchorItem anchor.edges: fromEdge anchor.gravity: fromEdge - anchor.adjustment: PopupAdjustment.SlideX + anchor.adjustment: horizontal + ? PopupAdjustment.SlideY + : PopupAdjustment.SlideX anchor.margins.top: Theme.popoutGap anchor.margins.bottom: Theme.popoutGap + anchor.margins.left: Theme.popoutGap + anchor.margins.right: Theme.popoutGap implicitWidth: contentWidth + bleed * 2 implicitHeight: contentHeight + bleed * 2 @@ -83,7 +96,14 @@ PopupWindow { height: root.contentHeight opacity: 0 - transformOrigin: Item.Top + transformOrigin: { + switch (root.fromEdge) { + case Edges.Left: return Item.Right; + case Edges.Right: return Item.Left; + case Edges.Top: return Item.Bottom; + default: return Item.Top; + } + } scale: 0.94 // The handler has to live on the content item, not the window: a @@ -105,8 +125,8 @@ PopupWindow { to: 1.0; duration: Theme.durSlow; easing.type: Easing.OutBack } NumberAnimation { - target: contentHolder; property: "y" - from: root.bleed - 14; to: root.bleed + target: contentHolder; property: root.slideProperty + from: root.slideOrigin; to: root.bleed duration: Theme.durSlow; easing.type: Easing.OutBack } } @@ -122,12 +142,14 @@ PopupWindow { to: 0.96; duration: Theme.durFast; easing.type: Easing.InQuad } NumberAnimation { - target: contentHolder; property: "y" - to: root.bleed - 8; duration: Theme.durFast; easing.type: Easing.InQuad + target: contentHolder; property: root.slideProperty + to: root.bleed + (root.slideOrigin - root.bleed) * 0.6 + duration: Theme.durFast; easing.type: Easing.InQuad } onFinished: { root.shown = false; root.pinned = false; + contentHolder.x = root.bleed; contentHolder.y = root.bleed; root.dismissedFully(); } diff --git a/quickshell/config/Theme.qml b/quickshell/config/Theme.qml index 13245e0..9782be1 100644 --- a/quickshell/config/Theme.qml +++ b/quickshell/config/Theme.qml @@ -3,12 +3,19 @@ pragma Singleton import QtQuick import Quickshell -// Takemi palette: cold teal/cyan over near-black, with Persona 5 crimson as the -// alert/accent voice. Derived from ~/.wallpapers/takemi_2.jpg. +// Takemi palette. Two hues, no exceptions: a cold teal that carries every +// resting state, and Persona 5 crimson that means alarm and nothing else. +// Anything that used to be blue/green/amber now resolves into one of those two +// families, so a splash of red in the bar always reads as "something is wrong". +// Derived from ~/.wallpapers/takemi_2.jpg. Singleton { id: root // ---------------------------------------------------------------- colors + // True black, for keylines and drop shapes. `base` is the near-black the + // surfaces sit on; `ink` is never tinted and never blended. + readonly property color ink: "#000000" + readonly property color base: "#04070a" readonly property color mantle: "#080e14" readonly property color surface: "#0d1922" @@ -23,13 +30,17 @@ Singleton { readonly property color primary: "#34d3e6" // electric teal — the house colour readonly property color primaryDim: "#17879b" readonly property color glow: "#8ef2ff" - readonly property color blue: "#3b8fd6" readonly property color deep: "#0d4b5e" - readonly property color accent: "#ff2d40" // P5 crimson + readonly property color accent: "#ff2d40" // P5 crimson — alarm, only ever alarm readonly property color accentDim: "#a8121f" - readonly property color warn: "#ffb03a" - readonly property color ok: "#35e08f" + readonly property color accentSoft: "#ff6b78" // the "getting there" step below accent + + // Retired hues, kept as aliases so call sites keep working while resolving + // into the two-hue system. Do not introduce new uses. + readonly property color blue: root.primaryDim + readonly property color ok: root.primary + readonly property color warn: root.accentSoft // Chromatic aberration pair, straight off the wallpaper's RGB split. readonly property color splitRed: "#ff2b4d" @@ -39,12 +50,21 @@ Singleton { return Qt.rgba(c.r, c.g, c.b, a); } - // Heat ramp for load/usage readouts. + // Linear blend, t = 0 gives a, t = 1 gives b. + function mix(a: color, b: color, t: real): color { + const k = Math.max(0, Math.min(1, t)); + return Qt.rgba(a.r + (b.r - a.r) * k, + a.g + (b.g - a.g) * k, + a.b + (b.b - a.b) * k, + a.a + (b.a - a.a) * k); + } + + // Heat ramp for load/usage readouts. Deliberately *not* a hue cycle: it sits + // flat teal until load actually matters, then bleeds continuously into + // crimson. A number that has gone red means the same thing everywhere. function heat(pct: real): color { - if (pct >= 90) return root.accent; - if (pct >= 75) return root.warn; - if (pct >= 45) return root.glow; - return root.primary; + if (pct <= 55) return root.primary; + return root.mix(root.primary, root.accent, (pct - 55) / 45); } // ----------------------------------------------------------------- fonts @@ -68,11 +88,26 @@ Singleton { readonly property int barMargin: 8 readonly property int barGap: 8 + // Right-hand status rail and the floating bottom-left dock. + readonly property int railWidth: 48 + readonly property int railGap: 10 + readonly property int dockMargin: 14 + // Low enough that the cluster stops competing with whatever window is + // underneath it — at 0.25 it just muddies text it happens to overlap. The + // corner wedge stays fully opaque as the thing you aim at. + readonly property real dockIdleOpacity: 0.13 + readonly property int dockRevealZone: 140 // px of screen bottom that wakes it + // Horizontal offset per unit of height. tan(14deg) — every panel leans. readonly property real skew: 0.249 readonly property int cut: 9 // corner chamfer, in px readonly property int stroke: 2 + // Pure-black keyline copy sitting behind every panel. This is what makes a + // shape read as screen-printed rather than as a clipped rectangle. + readonly property real dropOffset: 4 + readonly property real keylineWidth: 2 + readonly property int padS: 6 readonly property int padM: 12 readonly property int padL: 20 @@ -81,6 +116,24 @@ Singleton { readonly property int popoutWidth: 380 readonly property int popoutGap: 10 + // ------------------------------------------------------- shape variance + // Persona geometry is never a repeating pattern, so modules pull their lean + // and chamfer from these tables by index rather than sharing one default. + // Deterministic, so the bar looks the same every launch. + readonly property var leanTable: [0.32, 0.18, 0.26, 0.0, 0.29, 0.14, 0.35, 0.21] + readonly property var chopTable: [12, 5, 18, 8, 14, 4, 10, 16] + readonly property var cornerTable: [ + [1, 3], [0, 2], [1], [0, 2, 3], [3], [1, 3], [2], [0, 1, 3] + ] + + function leanFor(i: int): real { return root.leanTable[i % root.leanTable.length]; } + function chopFor(i: int): real { return root.chopTable[i % root.chopTable.length]; } + function cornersFor(i: int): var { return root.cornerTable[i % root.cornerTable.length]; } + + // Every other module leans against its neighbour, so the row reads as + // opposing diagonals instead of a comb. + function leanLeftFor(i: int): bool { return i % 3 === 1; } + // ------------------------------------------------------------ animation // P5 motion is snappy and slightly overshooting — never soft. readonly property int durFast: 110 diff --git a/quickshell/overlays/PowerMenu.qml b/quickshell/overlays/PowerMenu.qml index 23aee4b..2aefbf3 100644 --- a/quickshell/overlays/PowerMenu.qml +++ b/quickshell/overlays/PowerMenu.qml @@ -141,6 +141,11 @@ PanelWindow { anchors.verticalCenter: parent.verticalCenter spacing: Theme.padM + // The widest slab in the stack. Fixed rather than derived so the ragged + // x offsets below cannot feed back into the Column's own width. + readonly property int stackWidth: 312 + width: stackWidth + Repeater { id: buttonRepeater @@ -160,11 +165,29 @@ PanelWindow { required property var modelData required property int index - width: 280 + // Six identical slabs stacked at a constant width is the most + // obviously machine-made thing in the shell. Vary the width and + // push each row a different distance off the right margin, so + // the stack has a ragged edge on both sides. + readonly property int slabWidth: [286, 252, 300, 268, 312, 244][entry.index] + readonly property int nudge: [0, 22, 8, 34, 14, 26][entry.index] + + // Column owns y, so the ragged edge is an explicit x offset + // measured against the widest slab in the stack. + width: entry.slabWidth height: 52 + x: actions.stackWidth - entry.slabWidth - entry.nudge opacity: 0 - transform: Translate { id: slide; x: 90 } + transform: [ + Translate { id: slide; x: 90 }, + Rotation { + id: tilt + angle: -3 + origin.x: entry.slabWidth / 2 + origin.y: 26 + } + ] P5Button { anchors.fill: parent @@ -172,7 +195,8 @@ PanelWindow { icon: entry.modelData.icon destructive: entry.modelData.danger accent: entry.modelData.danger ? Theme.accent : Theme.primary - lunge: -10 + lean: Theme.leanFor(entry.index) + lunge: -10 - entry.index * 2 onClicked: root.invoke(entry.modelData.act) } @@ -199,6 +223,16 @@ PanelWindow { duration: Theme.durSlow easing.type: Easing.OutBack } + // Slabs land out of true and snap square. Sliding alone + // reads like a slide deck; the tilt is what makes it + // land like a card being thrown down. + NumberAnimation { + target: tilt; property: "angle" + from: entry.index % 2 === 0 ? -4 : 4; to: 0 + duration: Theme.durSlow + easing.type: Easing.OutBack + easing.overshoot: 2.2 + } } } } diff --git a/quickshell/shell.qml b/quickshell/shell.qml index a8ecadd..6457ae8 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -36,6 +36,18 @@ ShellRoot { Bar {} } + Variants { + model: Quickshell.screens + + Rail {} + } + + Variants { + model: Quickshell.screens + + Dock {} + } + Variants { model: Quickshell.screens