waffles: more continuous infinite scrolling calendar

This commit is contained in:
end-4
2025-11-26 22:57:40 +01:00
parent a786f0353e
commit d27fbede2a
10 changed files with 354 additions and 129 deletions
@@ -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;
}
}
@@ -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
}
}
}
}
@@ -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<var> 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
}
}
@@ -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]
}
}
}
}
@@ -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
@@ -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<DiffMonthGrid> 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
}
}
}
}
@@ -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"
}
}
}
@@ -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 {
@@ -12,6 +12,7 @@ import qs.modules.waffle.looks
FooterRectangle {
Layout.fillWidth: true
implicitWidth: 0
color: Looks.colors.bgPanelBody
RowLayout {
anchors {
@@ -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
}