From d27fbede2ac9f02d5ae4b8daf3806d37f069df78 Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:57:40 +0100 Subject: [PATCH] waffles: more continuous infinite scrolling calendar --- .../ii/modules/common/functions/DateUtils.qml | 38 +++++ .../modules/common/widgets/CalendarView.qml | 118 ++++++++++++++++ .../ii/modules/common/widgets/WeekRow.qml | 43 ++++++ .../ii/modules/waffle/looks/Looks.qml | 8 ++ .../ii/modules/waffle/looks/WButton.qml | 7 +- .../notificationCenter/CalendarView.qml | 124 ---------------- .../notificationCenter/CalendarWidget.qml | 132 ++++++++++++++++++ .../{CalendarHeader.qml => DateHeader.qml} | 6 +- .../waffle/notificationCenter/FocusFooter.qml | 1 + .../NotificationCenterContent.qml | 6 +- 10 files changed, 354 insertions(+), 129 deletions(-) create mode 100644 dots/.config/quickshell/ii/modules/common/functions/DateUtils.qml create mode 100644 dots/.config/quickshell/ii/modules/common/widgets/CalendarView.qml create mode 100644 dots/.config/quickshell/ii/modules/common/widgets/WeekRow.qml delete mode 100644 dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarView.qml create mode 100644 dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarWidget.qml rename dots/.config/quickshell/ii/modules/waffle/notificationCenter/{CalendarHeader.qml => DateHeader.qml} (87%) diff --git a/dots/.config/quickshell/ii/modules/common/functions/DateUtils.qml b/dots/.config/quickshell/ii/modules/common/functions/DateUtils.qml new file mode 100644 index 000000000..a7527bcb8 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/common/functions/DateUtils.qml @@ -0,0 +1,38 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function getMonday(date, american = false) { + const d = new Date(date); // Copy + const day = d.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday + + // Calculate difference to Monday + if (american) { + // Week starts on Sunday + d.setDate(d.getDate() - day); + } else { + // Week starts on Monday + const diff = day === 0 ? -6 : 1 - day; + d.setDate(d.getDate() + diff); + } + + return d; + } + + function sameDate(d1, d2) { + return ( + d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate() + ); + } + + function getIthDayDateOfSameWeek(date, i, american = false) { + const monday = root.getMonday(date, american); + const targetDate = new Date(monday); + targetDate.setDate(monday.getDate() + i); + return targetDate; + } +} diff --git a/dots/.config/quickshell/ii/modules/common/widgets/CalendarView.qml b/dots/.config/quickshell/ii/modules/common/widgets/CalendarView.qml new file mode 100644 index 000000000..56997ecba --- /dev/null +++ b/dots/.config/quickshell/ii/modules/common/widgets/CalendarView.qml @@ -0,0 +1,118 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.modules.waffle.looks + +Item { + id: root + + // Expose delegate + property Component delegate: Text { + required property var model + text: model.day + } + + // Configuration + property int paddingWeeks: 2 // 1 should be sufficient with proper clipping and no padding + property bool american: false // 🍔🦅 = Sunday first + + // Scrolling + function scrollMonthsAndSnap(x) { // Scroll x months and snap to month + const focusedDate = root.focusedDate; + const focusedMonth = focusedDate.getMonth(); + const focusedYear = focusedDate.getFullYear(); + const targetMonth = focusedMonth + x; + const targetDate = new Date(focusedYear, targetMonth, 1); + const currentFirstShownDate = new Date(root.dateInFirstWeek.getTime() + (root.paddingWeeks * root.millisPerWeek)); + const diffMillis = targetDate.getTime() - currentFirstShownDate.getTime(); + const diffWeeks = Math.round(diffMillis / root.millisPerWeek); + root.targetWeekDiff += diffWeeks; + } + property int weeksPerScroll: 1 + property real targetWeekDiff: 0 + property real weekDiff: targetWeekDiff + property int contentWeekDiff: weekDiff // whole part of weekDiff + property bool scrolling: false + + Behavior on weekDiff { + id: weekScrollBehavior + animation: Looks.transition.scroll.createObject(this) + } + Timer { + id: scrollAnimationCheckTimer + interval: 30 // Should be plenty for 60fps + onTriggered: root.scrolling = false; + } + onWeekDiffChanged: { + scrolling = true; + scrollAnimationCheckTimer.restart(); + } + + MouseArea { + anchors.fill: parent + onWheel: wheel => { + root.targetWeekDiff += wheel.angleDelta.y / 120 * -root.weeksPerScroll; // Reverse cuz scrolling down should advance + } + } + + // Date calculations + readonly property int millisPerWeek: 7 * 24 * 60 * 60 * 1000 + readonly property int totalWeeks: 6 + (paddingWeeks * 2) + readonly property int focusedWeekIndex: 2 // The third row, 0-indexed + readonly property int focusDayOfWeekIndex: 6 // Non-American + property date dateInFirstWeek: { + const currentDate = new Date(); + const currentMonth = currentDate.getMonth(); + const currentYear = currentDate.getFullYear(); + const firstDayThisMonth = new Date(currentYear, currentMonth, 1); + return new Date(firstDayThisMonth.getTime() - (paddingWeeks * millisPerWeek) + contentWeekDiff * millisPerWeek); + } + property date focusedDate: { + // The last day of 3rd week shown is considered the focused month + const addedTime = (root.paddingWeeks + root.focusedWeekIndex) * root.millisPerWeek + const dateInTargetWeek = new Date(root.dateInFirstWeek.getTime() + addedTime); + return DateUtils.getIthDayDateOfSameWeek(dateInTargetWeek, root.focusDayOfWeekIndex - (1 * root.american), root.american); // 4 = Thursday + } + property int focusedMonth: focusedDate.getMonth() + 1 // 0-indexed -> 1-indexed + + // Sizes + property real verticalPadding: 0 + property real buttonSize: 40 + property real buttonSpacing: 2 + implicitHeight: (6 * buttonSize) + (5 * buttonSpacing) + (2 * verticalPadding) + implicitWidth: weeksColumn.implicitWidth + clip: true + + ColumnLayout { + id: weeksColumn + anchors { + left: parent.left + right: parent.right + } + y: { + const spacePerExtraRow = root.buttonSize + root.buttonSpacing; + const origin = -(spacePerExtraRow * root.paddingWeeks); + const diff = root.weekDiff * spacePerExtraRow; + return origin + (-diff % spacePerExtraRow) + root.verticalPadding; + } + + spacing: root.buttonSpacing + Repeater { + model: root.totalWeeks + WeekRow { + required property int index + date: new Date(root.dateInFirstWeek.getTime() + (index * root.millisPerWeek)) + Layout.fillWidth: true + spacing: root.buttonSpacing + delegate: root.delegate + } + } + } +} diff --git a/dots/.config/quickshell/ii/modules/common/widgets/WeekRow.qml b/dots/.config/quickshell/ii/modules/common/widgets/WeekRow.qml new file mode 100644 index 000000000..2863f8896 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/common/widgets/WeekRow.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.services +import qs.modules.common.functions + +RowLayout { + id: root + + // Pls supply + required property date date + property bool sundayFirst: false + + // Expose model and delegate for flexibility + property list model: { + // Should expose props like here: https://doc.qt.io/qt-6/qml-qtquick-controls-monthgrid.html#delegate-prop + // (except weekNumber because i'm lazy and it's not so important) + const firstDayOfWeek = DateUtils.getMonday(root.date, root.sundayFirst); + const weekDates = []; + for (let i = 0; i < 7; i++) { + const dayDate = new Date(firstDayOfWeek); + dayDate.setDate(firstDayOfWeek.getDate() + i); + weekDates.push({ + date: dayDate, + day: dayDate.getDate(), + month: dayDate.getMonth() + 1, + year: dayDate.getFullYear(), + today: DateUtils.sameDate(dayDate, DateTime.clock.date) + }); + } + return weekDates; + } + property Component delegate: Text { + required property var model + text: model.day + } + + // Obvious + Repeater { + model: root.model + delegate: root.delegate + } +} diff --git a/dots/.config/quickshell/ii/modules/waffle/looks/Looks.qml b/dots/.config/quickshell/ii/modules/waffle/looks/Looks.qml index 0350f5004..e55f840cb 100644 --- a/dots/.config/quickshell/ii/modules/waffle/looks/Looks.qml +++ b/dots/.config/quickshell/ii/modules/waffle/looks/Looks.qml @@ -231,5 +231,13 @@ Singleton { easing.bezierCurve: transition.easing.bezierCurve.easeIn } } + + property Component scroll: Component { + NumberAnimation { + duration: 250 + easing.type: Easing.BezierSpline + easing.bezierCurve: [0.0, 0.0, 0.25, 1.0, 1, 1] + } + } } } diff --git a/dots/.config/quickshell/ii/modules/waffle/looks/WButton.qml b/dots/.config/quickshell/ii/modules/waffle/looks/WButton.qml index 18fe0244b..6b2bc4ecb 100644 --- a/dots/.config/quickshell/ii/modules/waffle/looks/WButton.qml +++ b/dots/.config/quickshell/ii/modules/waffle/looks/WButton.qml @@ -17,6 +17,7 @@ Button { property color colBackgroundToggledActive: Looks.colors.accentActive property color colForeground: Looks.colors.fg property color colForegroundToggled: Looks.colors.accentFg + property color colForegroundDisabled: ColorUtils.transparentize(Looks.colors.subfg, 0.4) property alias backgroundOpacity: backgroundRect.opacity property color color: { if (!root.enabled) return colBackground; @@ -37,7 +38,11 @@ Button { return root.colBackground; } } - property color fgColor: root.checked ? root.colForegroundToggled : root.colForeground + property color fgColor: { + if (root.checked) return root.colForegroundToggled + if (root.enabled) return root.colForeground + return root.colForegroundDisabled + } property alias horizontalAlignment: buttonText.horizontalAlignment font { family: Looks.font.family.ui diff --git a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarView.qml b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarView.qml deleted file mode 100644 index b0ff57684..000000000 --- a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarView.qml +++ /dev/null @@ -1,124 +0,0 @@ -pragma ComponentBehavior: Bound -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import qs -import qs.services -import qs.modules.common -import qs.modules.common.functions -import qs.modules.waffle.looks - -// TODO: The overlaps are crazy, but the positioning approach works. -// This could work well if we do it week by week instead of month by month. -BodyRectangle { - id: root - - // State - property bool collapsed - - // Sizes - property int _rowsPerMonth: 6 - property real viewHeight: (_rowsPerMonth * buttonSize) + ((_rowsPerMonth - 1) * buttonSpacing) - property real buttonSize: 40 - property real buttonSpacing: 2 - property real spacePerExtraRow: buttonSize + buttonSpacing - - implicitWidth: currentMonthGrid.implicitWidth - implicitHeight: collapsed ? 0 : viewHeight - opacity: implicitHeight > 0 ? 1 : 0 - - Behavior on implicitHeight { - animation: Looks.transition.enter.createObject(this) - } - - // Month stuff - property real targetMonthDiff: 0 - property real monthDiff: targetMonthDiff - property int focusedMonthDiff: monthDiff // whole part of monthDiff - property int currentMonth: DateTime.clock.date.getMonth() + 1 // 0-indexed -> 1-indexed - property int currentYear: DateTime.clock.date.getFullYear() - - clip: true - property list monthGrids: [previousPreviousMonthGrid, previousMonthGrid, currentMonthGrid, nextMonthGrid, nextNextMonthGrid] - ColumnLayout { - spacing: 0 - y: { - const origin = - currentMonthGrid.y; - const diff = root.monthDiff * root.viewHeight; - return origin + (-diff % root.viewHeight); - } - DiffMonthGrid { - id: previousPreviousMonthGrid - monthDiff: root.focusedMonthDiff - 2 - } - DiffMonthGrid { - id: previousMonthGrid - monthDiff: root.focusedMonthDiff - 1 - } - DiffMonthGrid { - id: currentMonthGrid - monthDiff: root.focusedMonthDiff - } - DiffMonthGrid { - id: nextMonthGrid - monthDiff: root.focusedMonthDiff + 1 - } - DiffMonthGrid { - id: nextNextMonthGrid - monthDiff: root.focusedMonthDiff + 2 - } - } - - MouseArea { - anchors.fill: parent - onWheel: wheel => { - root.targetMonthDiff += wheel.angleDelta.y / 120 * -0.333333; // Reverse cuz scrolling down should advance - } - } - - Behavior on monthDiff { - animation: Looks.transition.enter.createObject(this) - } - - component DiffMonthGrid: MonthGrid { - id: monthGrid - required property int monthDiff - property int index: root.monthGrids.indexOf(this) - month: ((root.currentMonth - 1) + monthDiff) % 12 // 1-indexed -> 0-indexed - year: root.currentYear + Math.floor((root.currentMonth - 1 + monthDiff) / 12) - - spacing: root.buttonSpacing - // background: Rectangle { - // color: Qt.rgba(Math.abs(Math.sin(month * 12.9898)) % 1, Math.abs(Math.sin(month * 78.233)) % 1, Math.abs(Math.sin(month * 45.164)) % 1, 1) - // } - delegate: MonthDayButton {} - } - - component MonthDayButton: WButton { - id: monthDayButton - required property var model - opacity: model.month == parent.parent.month || model.today ? 1 : 0 - checked: model.today - implicitWidth: root.buttonSize - implicitHeight: root.buttonSize - radius: height / 2 - - required property int index - - contentItem: Item { - WText { - anchors.centerIn: parent - text: monthDayButton.model.day - color: { - if (monthDayButton.model.today) - return Looks.colors.accentFg; - if (monthDayButton.model.month == root.currentMonth - 1) - return Looks.colors.fg; - return Looks.colors.subfg; - } - font.pixelSize: Looks.font.pixelSize.large - } - } - } -} diff --git a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarWidget.qml b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarWidget.qml new file mode 100644 index 000000000..23fc7c6eb --- /dev/null +++ b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarWidget.qml @@ -0,0 +1,132 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.modules.waffle.looks + +BodyRectangle { + id: root + + // State + property bool collapsed + + implicitHeight: collapsed ? 0 : contentColumn.implicitHeight + implicitWidth: contentColumn.implicitWidth + + Behavior on implicitHeight { + animation: Looks.transition.enter.createObject(this) + } + + clip: true + ColumnLayout { + id: contentColumn + spacing: 12 + CalendarHeader { + Layout.topMargin: 10 + Layout.fillWidth: true + } + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 5 + Layout.rightMargin: 5 + spacing: 1 + DayOfWeekRow { + Layout.fillWidth: true + locale: Qt.locale("en-GB") + spacing: calendarView.buttonSpacing + implicitHeight: calendarView.buttonSize + delegate: Item { + id: dayOfWeekItem + required property var model + implicitHeight: calendarView.buttonSize + implicitWidth: calendarView.buttonSize + WText { + anchors.centerIn: parent + text: dayOfWeekItem.model.shortName.substring(0,2) + color: Looks.colors.fg + font.pixelSize: Looks.font.pixelSize.large + } + } + } + CalendarView { + id: calendarView + verticalPadding: 2 + buttonSize: 41 // ??? + buttonSpacing: 1 + Layout.fillWidth: true + delegate: DayButton {} + } + } + } + + component DayButton: WButton { + id: dayButton + required property var model + checked: model.today + enabled: hovered || calendarView.scrolling || checked || model.month === calendarView.focusedMonth + implicitWidth: calendarView.buttonSize + implicitHeight: calendarView.buttonSize + radius: height / 2 + + required property int index + + contentItem: Item { + WText { + anchors.centerIn: parent + text: dayButton.model.day + color: dayButton.fgColor + font.pixelSize: Looks.font.pixelSize.large + } + } + } + + component CalendarHeader: RowLayout { + Layout.leftMargin: 8 + Layout.rightMargin: 8 + spacing: 8 + + WBorderlessButton { + Layout.fillWidth: true + implicitHeight: 34 + contentItem: Item { + WText { + anchors.fill: parent + horizontalAlignment: Text.AlignLeft + text: Qt.locale().toString(calendarView.dateInFirstWeek, "MMMM yyyy") + font.pixelSize: Looks.font.pixelSize.large + font.weight: Looks.font.weight.strong + } + } + } + ScrollMonthButton { + scrollDown: false + } + ScrollMonthButton { + scrollDown: true + } + } + + component ScrollMonthButton: WBorderlessButton { + id: scrollMonthButton + required property bool scrollDown + Layout.alignment: Qt.AlignVCenter + + onClicked: { + calendarView.scrollMonthsAndSnap(scrollDown ? 1 : -1); + } + implicitWidth: 32 + implicitHeight: 34 + + contentItem: FluentIcon { + filled: true + implicitSize: 12 + icon: scrollMonthButton.scrollDown ? "caret-down" : "caret-up" + } + } +} diff --git a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarHeader.qml b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/DateHeader.qml similarity index 87% rename from dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarHeader.qml rename to dots/.config/quickshell/ii/modules/waffle/notificationCenter/DateHeader.qml index 3e177f2fa..99e0666ed 100644 --- a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/CalendarHeader.qml +++ b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/DateHeader.qml @@ -12,8 +12,12 @@ import qs.modules.waffle.looks FooterRectangle { id: root - property bool collapsed implicitWidth: 0 + property bool collapsed + color: ColorUtils.transparentize(Looks.colors.bgPanelBody, collapsed ? 0 : 1) + Behavior on color { + animation: Looks.transition.color.createObject(this) + } RowLayout { anchors { diff --git a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/FocusFooter.qml b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/FocusFooter.qml index c66925d04..c222e41c4 100644 --- a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/FocusFooter.qml +++ b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/FocusFooter.qml @@ -12,6 +12,7 @@ import qs.modules.waffle.looks FooterRectangle { Layout.fillWidth: true implicitWidth: 0 + color: Looks.colors.bgPanelBody RowLayout { anchors { diff --git a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/NotificationCenterContent.qml b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/NotificationCenterContent.qml index c2b97f6dc..ed6677854 100644 --- a/dots/.config/quickshell/ii/modules/waffle/notificationCenter/NotificationCenterContent.qml +++ b/dots/.config/quickshell/ii/modules/waffle/notificationCenter/NotificationCenterContent.qml @@ -30,7 +30,7 @@ WBarAttachedPanelContent { WPane { contentItem: ColumnLayout { spacing: 0 - CalendarHeader { + DateHeader { Layout.fillWidth: true Synchronizer on collapsed { property alias source: root.collapsed @@ -39,8 +39,8 @@ WBarAttachedPanelContent { WPanelSeparator { visible: !root.collapsed } - CalendarView { - // Layout.fillWidth: true + CalendarWidget { + Layout.fillWidth: true Synchronizer on collapsed { property alias source: root.collapsed }