feat: dashboard show on hover, media and weather tabs added

This commit is contained in:
2026-09-30 14:22:22 +02:00
parent d259c6ffbe
commit 2c90339c77
97 changed files with 4480 additions and 990 deletions
+137 -38
View File
@@ -5,78 +5,177 @@ import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell
import qs.Components
Item {
id: root
required property PersistentProperties dashState
readonly property real nonAnimHeight: view.implicitHeight + viewWrapper.anchors.margins * 2
readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2
required property PersistentProperties visibilities
readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2
required property ScreenState screenState
readonly property var dashboardTabs: {
const allTabs = [
{
component: dashComponent,
iconName: "dashboard",
text: qsTr("Dashboard"),
enabled: true
},
{
component: weatherComponent,
iconName: "cloud",
text: qsTr("Weather"),
enabled: true
},
{
component: mediaComponent,
iconName: "queue_music",
text: qsTr("Media"),
enabled: true
},
// {
// component: testingComponent,
// iconName: "speed",
// text: qsTr("Testing"),
// enabled: true
// },
];
return allTabs.filter(tab => tab.enabled);
}
implicitHeight: nonAnimHeight
implicitWidth: nonAnimWidth
implicitHeight: nonAnimHeight
Behavior on implicitHeight {
Anim {
}
Anim {}
}
Behavior on implicitWidth {
Anim {
}
Anim {}
}
Tabs {
id: tabs
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins)
anchors.margins: Tokens.padding.large
nonAnimWidth: root.nonAnimWidth - anchors.margins * 2
screenState: root.screenState
tabs: root.dashboardTabs
}
ClippingRectangle {
id: viewWrapper
anchors.bottom: parent.bottom
anchors.top: tabs.bottom
anchors.left: parent.left
anchors.margins: Tokens.padding.small
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.small
color: "transparent"
radius: Tokens.rounding.extraLarge - anchors.margins
Item {
Flickable {
id: view
readonly property int currentIndex: root.dashState?.currentTab
readonly property Item currentItem: row.children[currentIndex]
readonly property int currentIndex: root.screenState.dashboardTab
readonly property Item currentItem: {
repeater.count;
return repeater.itemAt(currentIndex);
}
anchors.fill: parent
implicitHeight: currentItem.implicitHeight
implicitWidth: currentItem.implicitWidth
implicitWidth: currentItem?.implicitWidth ?? 0
implicitHeight: currentItem?.implicitHeight ?? 0
contentX: currentItem?.x ?? 0
contentWidth: row.implicitWidth
contentHeight: row.implicitHeight
interactive: false
onContentXChanged: {
if (!moving || !currentItem)
return;
const x = contentX - currentItem.x;
if (x > currentItem.implicitWidth / 2)
root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1);
else if (x < -currentItem.implicitWidth / 2)
root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0);
}
RowLayout {
id: row
Pane {
index: 0
Repeater {
id: repeater
sourceComponent: Dash {
dashState: root.dashState
visibilities: root.visibilities
model: ScriptModel {
values: root.dashboardTabs
}
delegate: Loader {
id: paneLoader
required property int index
required property var modelData
Layout.alignment: Qt.AlignTop
sourceComponent: modelData.component
Component.onCompleted: active = Qt.binding(() => {
if (index === view.currentIndex)
return true;
const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth);
const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth);
return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth);
})
}
}
}
Component {
id: dashComponent
Dash {
screenState: root.screenState
}
}
Component {
id: weatherComponent
WeatherTab {}
}
Component {
id: mediaComponent
Media {
screenState: root.screenState
}
}
// Component {
// id: testingComponent
//
// WeatherTest {}
// }
Behavior on contentX {
Anim {
type: Anim.DefaultEffects
}
}
}
}
component Pane: Loader {
id: pane
required property int index
Layout.alignment: Qt.AlignTop
Component.onCompleted: active = Qt.binding(() => {
// Always keep current tab loaded
if (pane.index === view.currentIndex)
return true;
const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth);
const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth);
return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth);
})
}
}
+5 -21
View File
@@ -11,10 +11,9 @@ import qs.Services
GridLayout {
id: root
required property PersistentProperties dashState
readonly property bool dashboardVisible: visibilities.dashboard
readonly property bool dashboardVisible: screenState.dashboard
property int radius: Tokens.rounding.medium
required property PersistentProperties visibilities
required property ScreenState screenState
columnSpacing: Tokens.spacing.small
rowSpacing: Tokens.spacing.small
@@ -61,7 +60,7 @@ GridLayout {
User {
id: user
dashState: root.dashState
screenState: root.screenState
}
}
@@ -72,8 +71,7 @@ GridLayout {
Layout.row: 0
radius: root.radius
Weather {
}
Weather {}
}
Rect {
@@ -87,7 +85,7 @@ GridLayout {
Calendar {
id: calendar
dashState: root.dashState
screenState: root.screenState
}
}
@@ -104,20 +102,6 @@ GridLayout {
}
}
CustomClippingRect {
Layout.column: 0
Layout.columnSpan: 5
Layout.fillWidth: true
Layout.preferredHeight: media.implicitHeight
Layout.row: 2
color: Colors.tPalette.m3surfaceContainer
radius: root.radius
Media {
id: media
}
}
component Rect: CustomRect {
color: Colors.tPalette.m3surfaceContainer
}
+9 -9
View File
@@ -12,12 +12,12 @@ CustomMouseArea {
id: root
property int activeGrid: 1
required property PersistentProperties dashState
required property ScreenState screenState
property bool initialized: false
property int month1
property int month2
readonly property int realCurrMonth: dashState.currentDate.getMonth()
readonly property int realCurrYear: dashState.currentDate.getFullYear()
readonly property int realCurrMonth: screenState.dashboardDate.getMonth()
readonly property int realCurrYear: screenState.dashboardDate.getFullYear()
property int year1
property int year2
@@ -43,9 +43,9 @@ CustomMouseArea {
function onWheel(event: WheelEvent): void {
if (event.angleDelta.y > 0)
root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1);
root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1);
else if (event.angleDelta.y < 0)
root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1);
root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1);
}
acceptedButtons: Qt.MiddleButton
@@ -61,7 +61,7 @@ CustomMouseArea {
initialized = true;
}
onClicked: root.dashState.currentDate = new Date()
onClicked: root.screenState.dashboardDate = new Date()
onRealCurrMonthChanged: handleDateChange()
onRealCurrYearChanged: handleDateChange()
@@ -135,7 +135,7 @@ CustomMouseArea {
padding: Tokens.padding.extraSmall
type: IconButton.Text
onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1)
onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1)
}
Item {
@@ -158,7 +158,7 @@ CustomMouseArea {
}
}
onClicked: root.dashState.currentDate = new Date()
onClicked: root.screenState.dashboardDate = new Date()
}
Item {
@@ -206,7 +206,7 @@ CustomMouseArea {
padding: Tokens.padding.extraSmall
type: IconButton.Text
onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1)
onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1)
}
}
-329
View File
@@ -1,329 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Services.Mpris
import QtQuick
import QtQuick.Layouts
import QtQuick.Shapes
import QtQuick.Effects
import ZShell.Components
import ZShell.Services
import ZShell.Config
import qs.Modules.Dashboard.Dash.Media
import qs.Daemons
import qs.Components
import qs.Helpers
import qs.Services
Item {
id: root
readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647
property real playerProgress: {
const active = Players.active;
return active?.length ? (active.position % active.length) / active.length : 0;
}
property int rowHeight: Tokens.padding.large + Config.dashboard.sizes.mediaProgressThickness + Tokens.spacing.small
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
function lengthStr(length: int): string {
if (length < 0)
return "-1:-1";
const hours = Math.floor(length / 3600);
const mins = Math.floor((length % 3600) / 60);
const secs = Math.floor(length % 60).toString().padStart(2, "0");
if (hours > 0)
return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`;
return `${mins}:${secs}`;
}
Behavior on playerProgress {
Anim {
duration: Tokens.anim.durations.large
}
}
Timer {
running: Players.active?.isPlaying ?? false
interval: Config.dashboard.mediaUpdateInterval
triggeredOnStart: true
repeat: true
onTriggered: Players.active?.positionChanged()
}
ServiceRef {
service: Audio.cava
}
BackgroundShapes {
anchors.fill: parent
}
Shape {
id: visualizer
readonly property int bars: Config.services.visualizerBars
property color color: Qt.alpha(Colors.palette.m3primary, 0.5)
property color fillColor: Qt.alpha(color, 0.1)
anchors.fill: parent
anchors.leftMargin: -(shape.strokeWidth / 2)
layer.enabled: true
asynchronous: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: shape
strokeColor: visualizer.color
fillColor: visualizer.fillColor
strokeWidth: 2
PathSvg {
path: shape.curvePath
}
property string curvePath: {
const values = Audio.cava.values;
const n = values.length;
if (n < 2)
return "";
const h = root.height + shape.strokeWidth / 2;
const w = root.width + shape.strokeWidth;
function x(i) {
return i * w / (n - 1);
}
function y(i) {
return h - (values[i] * Config.dashboard.sizes.mediaVisualizerSize) / 2;
}
let d = `M 0 ${h} `;
d += `L ${x(0)} ${y(0)} `;
for (let i = 0; i < n - 1; ++i) {
const x0 = x(i);
const y0 = y(i);
const x1 = x(i + 1);
const y1 = y(i + 1);
const prev = Math.max(0, i - 1);
const next = Math.min(n - 1, i + 2);
const c1x = x0 + (x1 - x(prev)) / 6;
const c1y = y0 + (y1 - y(prev)) / 6;
const c2x = x1 - (x(next) - x0) / 6;
const c2y = y1 - (y(next) - y0) / 6;
d += `C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x1} ${y1} `;
}
d += `L ${w} ${h} `;
d += `L 0 ${h} Z`;
return d;
}
}
}
CustomRect {
anchors.fill: visualizer
color: Qt.alpha(Colors.palette.m3shadow, 0.2)
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: visualizer
}
}
RowLayout {
id: layout
anchors.fill: parent
anchors.margins: Tokens.padding.large
anchors.leftMargin: Tokens.padding.extraLarge
CoverVisualizer {
Layout.fillHeight: true
implicitWidth: Config.dashboard.sizes.mediaCoverArtSize
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Tokens.spacing.largeIncreased
Layout.rightMargin: Tokens.spacing.largeIncreased
spacing: Tokens.spacing.small
MarqueeText {
id: title
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
color: Colors.palette.m3primary
font.pointSize: Tokens.font.size.normal
horizontalAlignment: Text.AlignHCenter
pauseMs: 4000
text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title")
implicitWidth: parent.width - Tokens.padding.large * 4
}
MarqueeText {
id: album
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
horizontalAlignment: Text.AlignHCenter
pauseMs: 4000
text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album")
implicitWidth: parent.width - Tokens.padding.large * 4
}
MarqueeText {
id: artist
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
color: Colors.palette.m3secondary
horizontalAlignment: Text.AlignHCenter
pauseMs: 4000
text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist")
implicitWidth: parent.width - Tokens.padding.large * 4
}
RowLayout {
id: positionSliderLayout
Layout.topMargin: Tokens.spacing.large
Layout.fillWidth: true
spacing: Tokens.spacing.small
TextMetrics {
id: timeMetrics
text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00"
}
CustomText {
id: positionLabel
Layout.preferredWidth: timeMetrics.width
text: root.lengthStr(Players.active?.position ?? -1)
color: Colors.palette.m3onSurfaceVariant
font: timeMetrics.font
horizontalAlignment: Text.AlignHCenter
}
CustomSlider {
id: positionSlider
Layout.fillWidth: true
value: Players.active ? Players.active.position / (Players.active.length || 1) : 0
enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength
wavy: true
animateWave: Players.active?.isPlaying ?? false
waveFrequency: 5
waveDuration: 2000
interactionOnMove: false
onInteraction: value => {
const active = Players.active;
if (active?.canSeek && active?.positionSupported)
active.position = value * active.length;
}
Binding {
target: positionLabel
property: "text"
value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0))
when: positionSlider.dragging
}
}
CustomText {
Layout.preferredWidth: timeMetrics.width
text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1)
color: Colors.palette.m3onSurfaceVariant
font: timeMetrics.font
horizontalAlignment: Text.AlignHCenter
}
}
ButtonRow {
Layout.topMargin: Tokens.spacing.largeIncreased
Layout.fillWidth: true
spacing: Tokens.spacing.extraSmall
Layout.leftMargin: positionSliderLayout.implicitWidth / 4
Layout.rightMargin: positionSliderLayout.implicitWidth / 4
IconButton {
type: IconButton.Tonal
icon: "shuffle"
isRound: true
shapeMorph: true
checked: Players.active?.shuffle ?? false
enabled: Players.active?.shuffleSupported
onClicked: Players.active.shuffle = !Players.active?.shuffle
}
IconButton {
id: previousBtn
type: IconButton.Tonal
icon: "skip_previous"
isRound: true
shapeMorph: true
enabled: Players.active?.canGoPrevious
onClicked: Players.active?.previous()
font.pointSize: Tokens.font.size.large
}
IconButton {
id: playPauseBtn
icon: Players.active?.isPlaying ? "pause" : "play_arrow"
isRound: true
shapeMorph: true
fillWidth: true
checked: Players.active?.isPlaying ?? false
enabled: Players.active?.canTogglePlaying
onClicked: Players.active?.togglePlaying()
font.pointSize: Tokens.font.size.large
}
IconButton {
id: nextBtn
type: IconButton.Tonal
icon: "skip_next"
isRound: true
shapeMorph: true
enabled: Players.active?.canGoNext
onClicked: Players.active?.next()
font.pointSize: Tokens.font.size.large
}
IconButton {
type: IconButton.Tonal
icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat"
isRound: true
shapeMorph: true
checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist
enabled: Players.active?.loopSupported
onClicked: {
const state = Players.active.loopState;
if (state === MprisLoopState.None)
Players.active.loopState = MprisLoopState.Track;
else if (state === MprisLoopState.Track)
Players.active.loopState = MprisLoopState.Playlist;
else
Players.active.loopState = MprisLoopState.None;
}
}
}
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import qs.Helpers
Row {
id: root
required property PersistentProperties dashState
required property ScreenState screenState
padding: 20
spacing: 12
+152
View File
@@ -0,0 +1,152 @@
import QtQuick
import QtQuick.Layouts
import M3Shapes
import ZShell.Config
import qs.Modules.Dashboard.Media
import qs.Components
import qs.Services
import qs.Helpers
Item {
id: root
property ScreenState screenState
implicitWidth: 1000
implicitHeight: 320
BackgroundShapes {
anchors.fill: parent
}
RowLayout {
anchors.fill: parent
anchors.margins: Tokens.padding.large
spacing: Tokens.spacing.extraLarge
CoverVisualizer {
Layout.fillHeight: true
implicitWidth: 250
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
state: Players.active ? "" : "noMedia"
states: State {
name: "noMedia"
PropertyChanges {
noMedia.opacity: 1
content.opacity: 0
}
}
transitions: [
Transition {
from: ""
SequentialAnimation {
Anim {
target: content
property: "opacity"
type: Anim.DefaultEffects
}
Anim {
target: noMedia
property: "opacity"
type: Anim.SlowEffects
}
}
},
Transition {
to: ""
SequentialAnimation {
Anim {
target: noMedia
property: "opacity"
type: Anim.DefaultEffects
}
Anim {
target: content
property: "opacity"
type: Anim.SlowEffects
}
}
}
]
Loader {
id: noMedia
anchors.centerIn: parent
anchors.horizontalCenterOffset: -Tokens.padding.extraLarge * 2
asynchronous: true
active: opacity > 0
opacity: 0
sourceComponent: ColumnLayout {
spacing: Tokens.spacing.small
MaterialShape {
Layout.topMargin: (pathBounds().height - implicitSize) / 2
Layout.bottomMargin: (pathBounds().height - implicitSize) / 2 + Tokens.spacing.small
Layout.alignment: Qt.AlignHCenter
color: Colors.palette.m3primaryContainer
implicitSize: icon.implicitHeight + Tokens.padding.extraLarge * 2
shape: MaterialShape.ClamShell
Behavior on color {
CAnim {}
}
MaterialIcon {
id: icon
anchors.centerIn: parent
text: "queue_music"
font.pointSize: Tokens.font.size.large
color: Colors.palette.m3onPrimaryContainer
}
}
CustomText {
Layout.alignment: Qt.AlignHCenter
text: qsTr("Nothing playing")
font.pointSize: Tokens.font.size.normal
}
CustomText {
text: qsTr("Play something for it to show up here!")
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.large
}
}
}
Loader {
id: content
anchors.fill: parent
asynchronous: true
active: opacity > 0
sourceComponent: RowLayout {
spacing: Tokens.spacing.extraLarge
Details {
Layout.fillWidth: true
}
LyricsAndSelector {
Layout.fillHeight: true
implicitWidth: 300
}
}
}
}
}
}
@@ -11,8 +11,8 @@ Item {
id: cover
anchors.centerIn: parent
implicitHeight: Config.dashboard.sizes.mediaCoverArtSize
implicitWidth: Config.dashboard.sizes.mediaCoverArtSize
implicitHeight: 200
implicitWidth: 200
shape.shape: MaterialShape.Cookie9Sided
}
}
+194
View File
@@ -0,0 +1,194 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Services.Mpris
import ZShell.Components
import ZShell.Config
import qs.Components
import qs.Services
import qs.Helpers
ColumnLayout {
id: root
readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647
function lengthStr(length: int): string {
if (length < 0)
return "-1:-1";
const hours = Math.floor(length / 3600);
const mins = Math.floor((length % 3600) / 60);
const secs = Math.floor(length % 60).toString().padStart(2, "0");
if (hours > 0)
return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`;
return `${mins}:${secs}`;
}
spacing: Tokens.spacing.extraSmall
Timer {
running: Players.active?.isPlaying ?? false
interval: Config.dashboard.mediaUpdateInterval
triggeredOnStart: true
repeat: true
onTriggered: Players.active?.positionChanged()
}
CustomText {
Layout.fillWidth: true
text: Players.active?.trackTitle ?? ""
font.pointSize: Tokens.font.size.large
elide: Text.ElideRight
animate: true
}
CustomText {
Layout.fillWidth: true
text: Players.active?.trackArtist || qsTr("Unknown artist")
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.normal
elide: Text.ElideRight
animate: true
}
CustomText {
Layout.fillWidth: true
text: Players.active?.trackAlbum || qsTr("Unknown album")
color: Colors.palette.m3secondary
font.pointSize: Tokens.font.size.normal
elide: Text.ElideRight
animate: true
}
RowLayout {
Layout.topMargin: Tokens.spacing.extraLargeIncreased
Layout.fillWidth: true
spacing: Tokens.spacing.small
TextMetrics {
id: timeMetrics
text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00"
font.pointSize: Tokens.font.size.normal
}
CustomText {
id: positionLabel
Layout.preferredWidth: timeMetrics.width
text: root.lengthStr(Players.active?.position ?? -1)
color: Colors.palette.m3onSurfaceVariant
font: timeMetrics.font
horizontalAlignment: Text.AlignHCenter
}
CustomSlider {
id: positionSlider
Layout.fillWidth: true
value: Players.active ? Players.active.position / (Players.active.length || 1) : 0
enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength
wavy: true
animateWave: Players.active?.isPlaying ?? false
waveFrequency: 5
waveDuration: 2000
interactionOnMove: false
onInteraction: value => {
const active = Players.active;
if (active?.canSeek && active?.positionSupported)
active.position = value * active.length;
}
Binding {
target: positionLabel
property: "text"
value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0))
when: positionSlider.dragging
}
}
CustomText {
Layout.preferredWidth: timeMetrics.width
text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1)
color: Colors.palette.m3onSurfaceVariant
font: timeMetrics.font
horizontalAlignment: Text.AlignHCenter
}
}
ButtonRow {
Layout.topMargin: Tokens.spacing.largeIncreased
Layout.fillWidth: true
spacing: Tokens.spacing.extraSmall
IconButton {
type: IconButton.Tonal
icon: "shuffle"
isRound: true
shapeMorph: true
checked: Players.active?.shuffle ?? false
font.pointSize: Tokens.font.size.large
enabled: Players.active?.shuffleSupported
onClicked: Players.active.shuffle = !Players.active?.shuffle
implicitWidth: Math.round(implicitHeight * 0.9)
}
IconButton {
id: previousBtn
type: IconButton.Tonal
icon: "skip_previous"
isRound: true
shapeMorph: true
font.pointSize: Tokens.font.size.extraLarge
enabled: Players.active?.canGoPrevious
onClicked: Players.active?.previous()
}
IconButton {
id: playPauseBtn
icon: Players.active?.isPlaying ? "pause" : "play_arrow"
isRound: true
shapeMorph: true
fillWidth: true
checked: Players.active?.isPlaying ?? false
font.pointSize: Tokens.font.size.extraLarge
enabled: Players.active?.canTogglePlaying
onClicked: Players.active?.togglePlaying()
}
IconButton {
id: nextBtn
type: IconButton.Tonal
icon: "skip_next"
isRound: true
shapeMorph: true
font.pointSize: Tokens.font.size.extraLarge
enabled: Players.active?.canGoNext
onClicked: Players.active?.next()
}
IconButton {
type: IconButton.Tonal
icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat"
isRound: true
shapeMorph: true
checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist
font.pointSize: Tokens.font.size.large
enabled: Players.active?.loopSupported
onClicked: {
const state = Players.active.loopState;
if (state === MprisLoopState.None)
Players.active.loopState = MprisLoopState.Track;
else if (state === MprisLoopState.Track)
Players.active.loopState = MprisLoopState.Playlist;
else
Players.active.loopState = MprisLoopState.None;
}
implicitWidth: Math.round(implicitHeight * 0.9)
}
}
}
+317
View File
@@ -0,0 +1,317 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import ZShell.Config
import ZShell.Services
import qs.Components
import qs.Effects
import qs.Services
import qs.Helpers
Item {
id: root
// Funny binding hack to make lyrics update
readonly property var _: {
const p = Players.active;
if (p)
Lyrics.setTrack(p.trackArtist, p.trackTitle, p.trackAlbum, p.length);
else
Lyrics.clearTrack();
}
readonly property real fadeAmount: 0.1
property list<string> lyricList: Lyrics.lyrics
layer.enabled: true
layer.effect: Mask {
maskSource: mask
Rectangle {
id: mask
layer.enabled: true
visible: false
implicitWidth: root.width
implicitHeight: root.height
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop {
color: Qt.alpha("black", 0)
position: 0
}
GradientStop {
color: Qt.alpha("black", 1)
position: root.fadeAmount
}
GradientStop {
color: Qt.alpha("black", 1)
position: 1 - root.fadeAmount
}
GradientStop {
color: Qt.alpha("black", 0)
position: 1
}
}
}
}
state: {
if (Lyrics.hasLyrics)
return "hasLyrics";
if (Lyrics.loading)
return "loading";
return "noLyrics";
}
states: [
State {
name: "loading"
PropertyChanges {
loadingIndicator.opacity: 1
lyrics.opacity: 0
noLyrics.opacity: 0
}
},
State {
name: "hasLyrics"
PropertyChanges {
loadingIndicator.opacity: 0
lyrics.opacity: 1
noLyrics.opacity: 0
}
},
State {
name: "noLyrics"
PropertyChanges {
loadingIndicator.opacity: 0
lyrics.opacity: 0
noLyrics.opacity: 1
}
}
]
transitions: [
Transition {
from: "loading"
SequentialAnimation {
Anim {
target: loadingIndicator
property: "opacity"
type: Anim.DefaultEffects
}
Anim {
targets: [lyrics, noLyrics]
property: "opacity"
type: Anim.SlowEffects
}
}
},
Transition {
from: "hasLyrics"
SequentialAnimation {
Anim {
target: lyrics
property: "opacity"
type: Anim.DefaultEffects
}
Anim {
targets: [loadingIndicator, noLyrics]
property: "opacity"
type: Anim.SlowEffects
}
}
},
Transition {
from: "noLyrics"
SequentialAnimation {
Anim {
target: noLyrics
property: "opacity"
type: Anim.DefaultEffects
}
Anim {
targets: [loadingIndicator, lyrics]
property: "opacity"
type: Anim.SlowEffects
}
}
}
]
Loader {
id: loadingIndicator
anchors.centerIn: parent
asynchronous: true
active: opacity > 0
opacity: 0
sourceComponent: ColumnLayout {
spacing: Tokens.spacing.large
CustomRect {
Layout.alignment: Qt.AlignHCenter
implicitWidth: shape.implicitSize + Tokens.padding.medium * 2
implicitHeight: shape.implicitSize + Tokens.padding.medium * 2
color: Colors.palette.m3primaryContainer
radius: Tokens.rounding.full
LoadingIndicator {
id: shape
anchors.centerIn: parent
implicitSize: Math.round(300 / 5)
containsIcon: true // This removes the pentagon, which is not centered
}
}
CustomText {
text: qsTr("Loading lyrics...")
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.normal
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Loader {
id: noLyrics
anchors.centerIn: parent
asynchronous: true
active: opacity > 0
opacity: 0
sourceComponent: ColumnLayout {
spacing: Tokens.spacing.small
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
text: "sentiment_sad"
font.pointSize: Tokens.font.size.extraLarge
color: Colors.palette.m3outline
}
CustomText {
text: qsTr("No lyrics found")
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.normal
}
}
}
CustomListView {
id: lyrics
anchors.fill: parent
anchors.topMargin: parent.height * root.fadeAmount / 2
anchors.bottomMargin: parent.height * root.fadeAmount / 2
displayMarginBeginning: anchors.topMargin
displayMarginEnd: anchors.bottomMargin
model: root.lyricList
Component.onCompleted: {
currentIndex = Qt.binding(() => {
model; // Force update when lyrics change
return Lyrics.indexForTime(Players.active?.position ?? 0);
});
positionViewAtIndex(currentIndex, ListView.Center);
}
onModelChanged: Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center))
highlightRangeMode: ListView.ApplyRange
highlightMoveDuration: Tokens.anim.durations.large
highlightMoveVelocity: -1
preferredHighlightBegin: (height - (currentItem?.implicitHeight ?? 0)) / 2
preferredHighlightEnd: (height + (currentItem?.implicitHeight ?? 0)) / 2
spacing: Tokens.spacing.small
opacity: 0
enabled: opacity > 0
delegate: CustomText {
id: lyric
required property string modelData
required property int index
property real effectScale: ListView.isCurrentItem ? 1 : 0
anchors.left: lyrics.contentItem.left
anchors.right: lyrics.contentItem.right
text: modelData || ". . ."
color: ListView.isCurrentItem ? Colors.palette.m3primary : mouse.containsMouse ? Colors.palette.m3onSurface : Colors.palette.m3outline
font.pointSize: Tokens.font.size.medium
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
layer.enabled: effectScale > 0
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: Colors.palette.m3primary
shadowOpacity: 0.5 * lyric.effectScale
shadowBlur: 0.6 * lyric.effectScale
blur: 0.4 * lyric.effectScale
}
Behavior on effectScale {
Anim {
type: Anim.SlowEffects
}
}
MouseArea {
id: mouse
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: {
const p = Players.active;
if (p)
p.position = Lyrics.timeForIndex(lyric.index);
}
}
}
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
Behavior on lyricList {
SequentialAnimation {
Anim {
target: lyrics
property: "opacity"
to: 0
type: Anim.DefaultEffects
}
PropertyAction {}
Anim {
target: lyrics
property: "opacity"
to: 1
type: Anim.SlowEffects
}
}
}
}
@@ -0,0 +1,79 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Mpris
import ZShell.Config
import qs.Components
import qs.Helpers
import qs.Services
Item {
ColumnLayout {
id: layout
anchors.fill: parent
anchors.leftMargin: Tokens.padding.medium
spacing: Tokens.spacing.medium
RowLayout {
Layout.bottomMargin: -Tokens.spacing.medium
spacing: Tokens.spacing.medium
z: 1
MaterialIcon {
Layout.topMargin: Math.round(fontInfo.pointSize * 0.12)
text: "lyrics"
font.pointSize: Tokens.font.size.large
}
CustomText {
Layout.fillWidth: true
text: qsTr("Lyrics")
font.pointSize: Tokens.font.size.large
}
LyricsInfo {}
}
LyricList {
Layout.fillWidth: true
Layout.fillHeight: true
}
SplitButton {
Layout.alignment: Qt.AlignHCenter
type: SplitButton.Tonal
enabled: Players.list.length
active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null
menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData
menuItems: playerList.instances
fallbackIcon: "music_off"
fallbackText: qsTr("No players", "no media players active")
minLeftWidth: layout.width - expandBtn.implicitWidth - spacing
label.Layout.maximumWidth: minLeftWidth - iconLabel.implicitWidth - textRow.spacing - textRow.anchors.horizontalCenterOffset / 2 - horizontalPadding * 2
label.elide: Text.ElideRight
stateLayer.enabled: true
menuOnTop: true
Variants {
id: playerList
model: Players.list
PlayerItem {}
}
}
}
component PlayerItem: MenuItem {
required property MprisPlayer modelData
icon: modelData === Players.active ? "check" : ""
text: Players.getIdentity(modelData)
activeIcon: "animated_images"
}
}
+212
View File
@@ -0,0 +1,212 @@
import QtQuick
import QtQuick.Layouts
import ZShell
import ZShell.Blobs
import ZShell.Config
import ZShell.Services
import qs.Components
import qs.Services
Item {
id: root
property bool open
readonly property real padding: Tokens.padding.large
implicitWidth: btn.implicitWidth * 0.9
implicitHeight: btn.implicitHeight * 0.9
BlobGroup {
id: blobGroup
color: Colors.palette.m3surfaceContainerHighest
smoothing: Tokens.rounding.medium
cornerFill: false
Behavior on color {
CAnim {}
}
}
BlobRect {
id: btnRect
anchors.fill: parent
anchors.margins: !btn.pressed && btn.containsMouse ? -Tokens.padding.extraSmall : 0
group: blobGroup
radius: Tokens.rounding.medium
Behavior on anchors.margins {
Anim {}
}
}
BlobRect {
id: rect
anchors.right: parent.right
anchors.top: parent.top
implicitWidth: parent.width
implicitHeight: parent.height
group: blobGroup
radius: Tokens.rounding.medium
deformScale: 0.00001
states: State {
name: "open"
when: root.open
PropertyChanges {
rect.anchors.rightMargin: root.width - Tokens.spacing.small
rect.anchors.topMargin: -Tokens.padding.medium
rect.implicitWidth: Math.max(layout.implicitWidth, placeholder.implicitWidth) + root.padding * 2
rect.implicitHeight: Math.max(layout.implicitHeight, placeholder.implicitHeight) + root.padding * 2
content.opacity: 1
}
}
transitions: Transition {
Anim {
properties: "rightMargin,implicitWidth"
}
Anim {
properties: "topMargin,implicitHeight"
easing: Tokens.anim.expressiveFastSpatial
}
Anim {
property: "opacity"
type: Anim.DefaultEffects
}
}
Behavior on implicitWidth {
Anim {}
}
Behavior on implicitHeight {
Anim {}
}
Item {
id: content
anchors.fill: parent
clip: true
opacity: 0
state: Lyrics.loading || !Lyrics.hasLyrics ? "" : "hasLyrics"
states: State {
name: "hasLyrics"
PropertyChanges {
layout.opacity: 1
placeholder.opacity: 0
}
}
transitions: [
Transition {
from: "hasLyrics"
SequentialAnimation {
Anim {
target: layout
property: "opacity"
type: Anim.FastEffects
}
Anim {
target: placeholder
property: "opacity"
type: Anim.DefaultEffects
}
}
},
Transition {
to: "hasLyrics"
SequentialAnimation {
Anim {
target: placeholder
property: "opacity"
type: Anim.FastEffects
}
Anim {
target: layout
property: "opacity"
type: Anim.DefaultEffects
}
}
}
]
ColumnLayout {
id: layout
anchors.centerIn: parent
spacing: Tokens.spacing.extraSmall
opacity: 0
CustomText {
// TRANSLATORS: %1 = lyrics backend name, e.g. LRCLIB
text: qsTr("Backend: %1").arg(ZUtils.enumToString(Lyrics, "backend"))
color: Colors.palette.m3onSurfaceVariant
animate: true
}
CustomText {
Layout.maximumWidth: 1000 / 2
// TRANSLATORS: %1/%2/%3 = matched track title, artist and album
text: qsTr("Selected candidate: %1 | %2 | %3").arg(Lyrics.selectedCandidate.title).arg(Lyrics.selectedCandidate.artist).arg(Lyrics.selectedCandidate.album)
color: Colors.palette.m3onSurfaceVariant
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
animate: true
}
CustomText {
// TRANSLATORS: %1 = lyric timing offset; ms is the millisecond unit, leave untranslated
text: qsTr("Offset: %1 ms").arg(Lyrics.offset)
color: Colors.palette.m3onSurfaceVariant
animate: true
}
}
Item {
id: placeholder
anchors.centerIn: parent
implicitWidth: placeholderText.implicitWidth
implicitHeight: placeholderText.implicitHeight
CustomText {
id: placeholderText
text: Lyrics.loading ? qsTr("Loading...") : qsTr("No lyrics found")
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.normal
animate: true
}
}
}
}
MouseArea {
id: btn
anchors.centerIn: parent
implicitWidth: implicitHeight
implicitHeight: icon.implicitHeight + Tokens.padding.extraSmall * 2
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: root.open = !root.open
MaterialIcon {
id: icon
anchors.centerIn: parent
text: "more_vert"
font.pointSize: Tokens.font.size.large
}
}
}
+90 -144
View File
@@ -1,23 +1,24 @@
pragma ComponentBehavior: Bound
import qs.Components
import ZShell.Config
import qs.Helpers
import qs.Modules
import Quickshell
import Quickshell.Widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Templates
import Quickshell
import ZShell.Config
import qs.Components
import qs.Services
Item {
id: root
readonly property alias count: bar.count
required property real nonAnimWidth
required property PersistentProperties state
required property ScreenState screenState
required property var tabs
implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight
readonly property alias count: bar.count
readonly property int tabIndicatorSpacing: Tokens.spacing.small
implicitHeight: bar.implicitHeight + bar.anchors.topMargin + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight
TabBar {
id: bar
@@ -25,85 +26,99 @@ Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: root.tabIndicatorSpacing
currentIndex: root.screenState.dashboardTab
onCurrentIndexChanged: root.screenState.dashboardTab = currentIndex
implicitHeight: contentHeight
background: null
currentIndex: root.state.currentTab
contentItem: RowLayout {
spacing: 0
onCurrentIndexChanged: root.state.currentTab = currentIndex
Tab {
iconName: "dashboard"
text: qsTr("Dashboard")
Repeater {
model: bar.contentModel
}
}
Tab {
iconName: "queue_music"
text: qsTr("Media")
}
Repeater {
model: ScriptModel {
values: root.tabs
}
Tab {
iconName: "speed"
text: qsTr("Performance")
}
delegate: Tab {
required property var modelData
Tab {
iconName: "cloud"
text: qsTr("Weather")
iconName: modelData.iconName
text: modelData.text
}
}
// Tab {
// iconName: "workspaces"
// text: qsTr("Workspaces")
// }
}
Item {
id: indicator
anchors.top: bar.bottom
clip: true
implicitHeight: 40
implicitWidth: bar.currentItem.implicitWidth
anchors.topMargin: root.tabIndicatorSpacing
implicitWidth: {
const tab = bar.currentItem;
if (tab)
return tab.implicitWidth;
const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count;
return width;
}
implicitHeight: 3
x: {
const tab = bar.currentItem;
const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count;
return width * tab.TabBar.index + (width - tab.implicitWidth) / 2;
const tabWidth = tab?.implicitWidth ?? width;
return width * bar.currentIndex + (width - tabWidth) / 2;
}
clip: true
CustomRect {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: parent.implicitHeight * 2
color: Colors.palette.m3primary
radius: Tokens.rounding.full
}
Behavior on x {
Anim {}
}
Behavior on implicitWidth {
Anim {
}
}
Behavior on x {
Anim {
}
}
CustomRect {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
color: Colors.palette.m3primary
implicitHeight: parent.implicitHeight * 2
radius: Tokens.rounding.full
Anim {}
}
}
CustomRect {
id: separator
anchors.top: indicator.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.top: indicator.bottom
color: Colors.palette.m3outlineVariant
implicitHeight: 1
color: Colors.palette.m3outlineVariant
}
component Tab: TabButton {
id: tab
readonly property bool current: TabBar.tabBar.currentItem === this
required property string iconName
readonly property bool current: TabBar.tabBar.currentItem === this
Layout.fillWidth: true
Layout.preferredWidth: 1 // Uniform width across all tabs
implicitWidth: implicitContentWidth
implicitHeight: implicitContentHeight
background: null
contentItem: CustomMouseArea {
@@ -111,117 +126,47 @@ Item {
function onWheel(event: WheelEvent): void {
if (event.angleDelta.y < 0)
root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1);
root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, bar.count - 1);
else if (event.angleDelta.y > 0)
root.state.currentTab = Math.max(root.state.currentTab - 1, 0);
root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0);
}
cursorShape: Qt.PointingHandCursor
implicitHeight: icon.height + label.height
implicitWidth: Math.max(icon.width, label.width)
implicitHeight: icon.height + label.height
onPressed: event => {
root.state.currentTab = tab.TabBar.index;
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
const stateY = stateWrapper.y;
rippleAnim.x = event.x;
rippleAnim.y = event.y - stateY;
onPressed: root.screenState.dashboardTab = tab.TabBar.index
const dist = (ox, oy) => ox * ox + oy * oy;
rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), dist(event.x, stateWrapper.height - event.y), dist(width - event.x, event.y + stateY), dist(width - event.x, stateWrapper.height - event.y)));
rippleAnim.restart();
}
SequentialAnimation {
id: rippleAnim
property real radius
property real x
property real y
PropertyAction {
property: "x"
target: ripple
value: rippleAnim.x
}
PropertyAction {
property: "y"
target: ripple
value: rippleAnim.y
}
PropertyAction {
property: "opacity"
target: ripple
value: 0.08
}
Anim {
from: 0
properties: "implicitWidth,implicitHeight"
target: ripple
to: rippleAnim.radius * 2
}
Anim {
easing.type: Easing.BezierSpline
property: "opacity"
target: ripple
to: 0
}
}
ClippingRectangle {
id: stateWrapper
StateLayer {
id: stateLayer
anchors.fill: undefined
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
implicitHeight: parent.height + 8 * 2
radius: Tokens.rounding.small
implicitHeight: parent.height + root.tabIndicatorSpacing * 2
CustomRect {
id: stateLayer
anchors.fill: parent
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface
opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0
Behavior on opacity {
Anim {
}
}
}
CustomRect {
id: ripple
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface
opacity: 0
radius: Tokens.rounding.full
transform: Translate {
x: -ripple.width / 2
y: -ripple.height / 2
}
}
radius: Tokens.rounding.medium
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface
onClicked: root.screenState.dashboardTab = tab.TabBar.index
}
MaterialIcon {
id: icon
anchors.bottom: label.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: label.top
text: tab.iconName
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
fill: tab.current ? 1 : 0
font.pointSize: 18
text: tab.iconName
font.pointSize: Tokens.font.size.large
Behavior on fill {
Anim {
type: Anim.DefaultEffects
}
}
}
@@ -229,10 +174,11 @@ Item {
CustomText {
id: label
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
anchors.bottom: parent.bottom
text: tab.text
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
}
}
}
+145
View File
@@ -0,0 +1,145 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import qs.Components
import qs.Services
import qs.Helpers
RowLayout {
id: root
spacing: Tokens.spacing.largeIncreased
readonly property list<var> forecastToday: Weather.hourlyForecast.slice(0, 25)
property var now: new Date()
property bool useTwelveHourClock: Config.services.useTwelveHourClock
property bool curveSmoothing: true
property real smoothing: 2
property real markerGap: Math.min(Math.max(Tokens.spacing.medium / 2, 0), 6)
readonly property real markerX: {
const d = root.now;
return d.getHours() + d.getMinutes() / 60;
}
function formatHour(h) {
if (root.useTwelveHourClock) {
const hr = h % 12 === 0 ? 12 : h % 12;
return hr + (h < 12 ? " AM" : " PM");
}
return h;
}
Timer {
interval: 60000
repeat: true
running: true
onTriggered: root.now = new Date()
}
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: root.width / 2
RowLayout {
Layout.fillWidth: true
MaterialIcon {
text: "device_thermostat"
font.pointSize: Tokens.font.size.large
color: Colors.palette.m3secondary
}
CustomText {
text: qsTr("Temperature")
}
}
Item {
id: temperature
implicitHeight: 150
Layout.fillWidth: true
LineGraph {
id: tempGraph
anchors.fill: parent
values: root.forecastToday.map(e => e.tempC)
verticalLineStep: 3
xSteps: 24
horizontalLineStep: 10
minHorizontalLines: 4
yPaddingTop: horizontalLineStep
yPaddingBottom: horizontalLineStep
xLabel: i => root.formatHour(i)
yLabel: v => Math.round(v) + "°"
markerX: root.markerX
smoothing: root.smoothing
curveSmoothing: root.curveSmoothing
markerGap: root.markerGap
lineColor: Colors.palette.m3secondary
gridColor: Colors.palette.m3onSurface
markerColor: Colors.palette.m3secondary
labelColor: Colors.palette.m3onSurfaceVariant
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: root.width / 2
RowLayout {
Layout.fillWidth: true
MaterialIcon {
text: "water_drop"
font.pointSize: Tokens.font.size.large
color: Colors.palette.m3tertiary
}
CustomText {
text: qsTr("Chance of precipitation")
}
}
Item {
id: precipitation
Layout.fillWidth: true
implicitHeight: 150
LineGraph {
id: graph
anchors.fill: parent
values: root.forecastToday.map(e => e?.precipChance ?? null)
xSteps: 24
yMin: 0
yMax: 100
yPaddingTop: 20
verticalLineStep: 3
lastXLine: false
horizontalLineStep: 20
xLabel: i => root.formatHour(i)
yLabel: v => v + "%"
markerX: root.markerX
smoothing: root.smoothing
curveSmoothing: root.curveSmoothing
markerGap: root.markerGap
lineColor: Colors.palette.m3tertiary
gridColor: Colors.palette.m3onSurface
markerColor: Colors.palette.m3tertiary
labelColor: Colors.palette.m3onSurfaceVariant
}
}
}
}
+259
View File
@@ -0,0 +1,259 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import qs.Components
import qs.Modules.Dashboard.Weather
import qs.Services
import qs.Helpers
Item {
id: root
readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null
implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840
implicitHeight: layout.implicitHeight
Component.onCompleted: Weather.reload()
ColumnLayout {
id: layout
anchors.fill: parent
spacing: Tokens.spacing.medium
RowLayout {
Layout.leftMargin: Tokens.padding.large
Layout.rightMargin: Tokens.padding.large
Layout.fillWidth: true
Column {
spacing: Tokens.spacing.extraSmall
CustomText {
text: Weather.city || qsTr("Loading...")
font.pointSize: Tokens.font.size.large
color: Colors.palette.m3onSurface
}
CustomText {
text: new Date().toLocaleDateString(Qt.locale("en_US"), "dddd, MMMM d")
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3onSurfaceVariant
}
}
Item {
Layout.fillWidth: true
}
Row {
spacing: Tokens.spacing.largeIncreased
WeatherStat {
icon: "wb_twilight"
label: qsTr("Sunrise")
value: Weather.sunrise
colour: Colors.palette.m3tertiary
}
WeatherStat {
icon: "bedtime"
label: qsTr("Sunset")
value: Weather.sunset
colour: Colors.palette.m3tertiary
}
}
}
CustomRect {
Layout.fillWidth: true
implicitHeight: bigInfoRow.implicitHeight + bigInfoRow.anchors.margins * 2
radius: Tokens.rounding.large
color: Colors.tPalette.m3surfaceContainer
Graphs {
id: bigInfoRow
anchors.fill: parent
anchors.margins: Tokens.padding.medium
}
}
RowLayout {
Layout.fillWidth: true
spacing: Tokens.spacing.medium
DetailCard {
icon: Weather.icon
label: qsTr("Temperature")
value: Weather.temp
colour: Colors.palette.m3primary
}
DetailCard {
icon: "thermostat"
label: qsTr("Feels like", "apparent temperature")
value: Weather.feelsLike
colour: Colors.palette.m3primary
}
DetailCard {
icon: "water_drop"
label: qsTr("Humidity")
value: Strings.percent(Weather.humidity)
colour: Colors.palette.m3secondary
}
DetailCard {
icon: "air"
label: qsTr("Wind")
value: Weather.windSpeed ? qsTr("%1 km/h").arg(Weather.windSpeed) : "--"
colour: Colors.palette.m3tertiary
}
}
CustomText {
Layout.topMargin: Tokens.spacing.medium
Layout.leftMargin: Tokens.padding.medium
visible: forecastRepeater.count > 0
text: qsTr("7-day forecast")
font.pointSize: Tokens.font.size.normal
color: Colors.palette.m3onSurface
}
RowLayout {
Layout.fillWidth: true
spacing: Tokens.spacing.medium
Repeater {
id: forecastRepeater
model: Weather.forecast
CustomRect {
id: forecastItem
required property int index
required property var modelData
Layout.fillWidth: true
implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.medium * 2
radius: Tokens.rounding.large
color: Colors.tPalette.m3surfaceContainer
ColumnLayout {
id: forecastItemColumn
anchors.centerIn: parent
spacing: Tokens.spacing.small
CustomText {
Layout.alignment: Qt.AlignHCenter
text: forecastItem.index === 0 ? qsTr("Today", "forecast column") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "ddd")
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3primary
}
CustomText {
Layout.topMargin: -Tokens.spacing.extraSmall
Layout.alignment: Qt.AlignHCenter
text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "MMM d")
font.pointSize: Tokens.font.size.small
opacity: 0.7
color: Colors.palette.m3onSurfaceVariant
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
text: forecastItem.modelData.icon
font.pointSize: Tokens.font.size.extraLarge
color: Colors.palette.m3secondary
}
CustomText {
Layout.alignment: Qt.AlignHCenter
text: {
const min = Weather.formatTemp(forecastItem.modelData.minTempC, true);
const max = Weather.formatTemp(forecastItem.modelData.maxTempC, true);
return qsTr("%1 / %2", "min/max temperature").arg(min).arg(max);
}
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3tertiary
}
}
}
}
}
}
component DetailCard: CustomRect {
id: detailRoot
property string icon
property string label
property string value
property color colour
Layout.fillWidth: true
Layout.preferredHeight: 60
radius: Tokens.rounding.medium
color: Colors.tPalette.m3surfaceContainer
Row {
anchors.centerIn: parent
spacing: Tokens.spacing.medium
MaterialIcon {
text: detailRoot.icon
color: detailRoot.colour
font.pointSize: Tokens.font.size.large
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 0
CustomText {
text: detailRoot.label
font.pointSize: Tokens.font.size.small
opacity: 0.7
horizontalAlignment: Text.AlignLeft
}
CustomText {
text: detailRoot.value
font.pointSize: Tokens.font.size.small
horizontalAlignment: Text.AlignLeft
}
}
}
}
component WeatherStat: Row {
id: weatherStat
property string icon
property string label
property string value
property color colour
spacing: Tokens.spacing.small
MaterialIcon {
text: weatherStat.icon
font.pointSize: Tokens.font.size.extraLarge
color: weatherStat.colour
}
Column {
CustomText {
text: weatherStat.label
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3onSurfaceVariant
}
CustomText {
text: weatherStat.value
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3onSurface
}
}
}
}
+7 -22
View File
@@ -8,24 +8,13 @@ import ZShell.Config
Item {
id: root
readonly property PersistentProperties dashState: PersistentProperties {
property date currentDate: new Date()
property int currentTab
reloadableId: "dashboardState"
}
readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0
required property real offsetScale
required property string position
readonly property bool shouldBeActive: root.visibilities.dashboard && Config.dashboard.enabled
required property PersistentProperties visibilities
readonly property bool shouldBeActive: root.screenState.dashboard && Config.dashboard.enabled
required property ScreenState screenState
anchors.bottom: position === "bottom" ? parent.bottom : undefined
anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0
anchors.left: position === "left" ? parent.left : undefined
anchors.leftMargin: position === "left" ? (-implicitWidth - 5) * offsetScale : 0
anchors.top: position === "top" ? parent.top : undefined
anchors.topMargin: position === "top" ? (-implicitHeight - 5) * offsetScale : 0
anchors.top: parent.top
anchors.topMargin: (-implicitHeight - 5) * offsetScale
implicitHeight: content.implicitHeight
implicitWidth: content.implicitWidth || 854
opacity: 1 - offsetScale
@@ -35,15 +24,11 @@ Item {
id: content
active: root.shouldBeActive || root.visible
anchors.bottom: root.position === "top" ? parent.bottom : undefined
anchors.horizontalCenter: root.position !== "left" ? parent.horizontalCenter : undefined
anchors.right: root.position !== "left" ? undefined : parent.right
anchors.top: root.position === "bottom" ? parent.top : undefined
anchors.verticalCenter: root.position === "left" ? undefined : parent.verticalCenter
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
sourceComponent: Content {
dashState: root.dashState
visibilities: root.visibilities
screenState: root.screenState
}
}
}