From bce57c101a26bf1c49f0e7ffa56060e02be4f43c Mon Sep 17 00:00:00 2001 From: quadbyte Date: Sat, 9 Aug 2025 18:00:57 -0400 Subject: [PATCH] Clock and calendar --- Helpers/Holidays.js | 81 ++++++++++++++++ Modules/Bar/Bar.qml | 2 +- Modules/Bar/Clock.qml | 56 +++++++++++ Services/.gitkeep | 0 Services/Scaling.qml | 2 +- Services/Settings.qml | 86 ++++++++++++++++- Services/Time.qml | 49 ++++++++++ Theme/Style.qml | 2 + Widgets/NCalendar.qml | 204 ++++++++++++++++++++++++++++++++++++++++ Widgets/NIconButton.qml | 4 + Widgets/NPanel.qml | 46 +++++++++ Widgets/NTooltip.qml | 2 +- 12 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 Helpers/Holidays.js create mode 100644 Modules/Bar/Clock.qml delete mode 100644 Services/.gitkeep create mode 100644 Services/Time.qml create mode 100644 Widgets/NCalendar.qml create mode 100644 Widgets/NPanel.qml diff --git a/Helpers/Holidays.js b/Helpers/Holidays.js new file mode 100644 index 0000000..4bb3a60 --- /dev/null +++ b/Helpers/Holidays.js @@ -0,0 +1,81 @@ +var _countryCode = null; +var _regionCode = null; +var _regionName = null; +var _holidaysCache = {}; + +function getCountryCode(callback) { + if (_countryCode) { + callback(_countryCode); + return; + } + var xhr = new XMLHttpRequest(); + xhr.open("GET", "https://nominatim.openstreetmap.org/search?city="+ Settings.settings.weatherCity+"&country=&format=json&addressdetails=1&extratags=1", true); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + var response = JSON.parse(xhr.responseText); + _countryCode = response?.[0]?.address?.country_code ?? "US"; + _regionCode = response?.[0]?.address?.["ISO3166-2-lvl4"] ?? ""; + _regionName = response?.[0]?.address?.state ?? ""; + callback(_countryCode); + } + } + xhr.send(); +} + +function getHolidays(year, countryCode, callback) { + var cacheKey = year + "-" + countryCode; + if (_holidaysCache[cacheKey]) { + callback(_holidaysCache[cacheKey]); + return; + } + var url = "https://date.nager.at/api/v3/PublicHolidays/" + year + "/" + countryCode; + var xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + var holidays = JSON.parse(xhr.responseText); + var augmentedHolidays = filterHolidaysByRegion(holidays); + _holidaysCache[cacheKey] = augmentedHolidays; + callback(augmentedHolidays); + } + } + xhr.send(); +} + +function filterHolidaysByRegion(holidays) { + if (!_regionCode) { + return holidays; + } + const retHolidays = []; + holidays.forEach(function(holiday) { + if (holiday.counties?.length > 0) { + let found = false; + holiday.counties.forEach(function(county) { + if (county.toLowerCase() === _regionCode.toLowerCase()) { + found = true; + } + }); + if (found) { + var regionText = " (" + _regionName + ")"; + holiday.name = holiday.name + regionText; + holiday.localName = holiday.localName + regionText; + retHolidays.push(holiday); + } + } else { + retHolidays.push(holiday); + } + }); + return retHolidays; +} + +function getHolidaysForMonth(year, month, callback) { + getCountryCode(function(countryCode) { + getHolidays(year, countryCode, function(holidays) { + var filtered = holidays.filter(function(h) { + var date = new Date(h.date); + return date.getFullYear() === year && date.getMonth() === month; + }); + callback(filtered); + }); + }); +} \ No newline at end of file diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 423e901..253cc6a 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -61,7 +61,7 @@ PanelWindow { NSlider {} - + Clock {} } } } diff --git a/Modules/Bar/Clock.qml b/Modules/Bar/Clock.qml new file mode 100644 index 0000000..57d9c20 --- /dev/null +++ b/Modules/Bar/Clock.qml @@ -0,0 +1,56 @@ +import QtQuick +import qs.Services +import qs.Theme +import qs.Widgets + +Rectangle { + id: root + + readonly property real scaling: Scaling.scale(screen) + + width: textItem.paintedWidth + height: textItem.paintedHeight + color: "transparent" + + Text { + id: textItem + text: Time.time + font.family: Theme.fontFamily + font.weight: Font.Bold + font.pointSize: Style.fontSmall * scaling + color: Theme.textPrimary + anchors.centerIn: parent + } + + MouseArea { + id: clockMouseArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onEntered: { + if (!calendar.visible) { + tooltip.show() + } + } + onExited: { + tooltip.hide() + } + onClicked: function () { + calendar.visible = !calendar.visible + if (calendar.visible) { + tooltip.hide(); + } + } + } + + NCalendar { + id: calendar + visible: false + } + + NTooltip { + id: tooltip + text: Time.dateString + target: root + } +} diff --git a/Services/.gitkeep b/Services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Services/Scaling.qml b/Services/Scaling.qml index d71fef4..02bf7b2 100644 --- a/Services/Scaling.qml +++ b/Services/Scaling.qml @@ -41,6 +41,6 @@ Singleton { } // 3) Safe default - return 1.0 + return 2.0 } } diff --git a/Services/Settings.qml b/Services/Settings.qml index fd6bfb9..6ec70e0 100644 --- a/Services/Settings.qml +++ b/Services/Settings.qml @@ -6,7 +6,6 @@ import Quickshell.Io import qs.Services Singleton { - id: root property string shellName: "Noctalia" property string settingsDir: Quickshell.env("NOCTALIA_SETTINGS_DIR") @@ -17,6 +16,7 @@ Singleton { || (settingsDir + "Settings.json") property string themeFile: Quickshell.env("NOCTALIA_THEME_FILE") || (settingsDir + "Theme.json") + property var settings: settingAdapter Item { Component.onCompleted: { @@ -24,4 +24,88 @@ Singleton { Quickshell.execDetached(["mkdir", "-p", settingsDir]) } } + + FileView { + id: settingFileView + path: settingsFile + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: writeAdapter() + Component.onCompleted: function () { + reload() + } + onLoaded: function () {// Qt.callLater(function () { + // WallpaperManager.setCurrentWallpaper(settings.currentWallpaper, true); + // }) + } + onLoadFailed: function (error) { + if (error.toString().includes("No such file") || error === 2) { + // File doesn't exist, create it with default values + writeAdapter() + } + } + JsonAdapter { + id: settingAdapter + property string weatherCity: "Dinslaken" + property string profileImage: Quickshell.env("HOME") + "/.face" + property bool useFahrenheit: false + property string wallpaperFolder: "/usr/share/wallpapers" + property string currentWallpaper: "" + property string videoPath: "~/Videos/" + property bool showActiveWindow: true + property bool showActiveWindowIcon: false + property bool showSystemInfoInBar: false + property bool showCorners: false + property bool showTaskbar: true + property bool showMediaInBar: false + property bool useSWWW: false + property bool randomWallpaper: false + property bool useWallpaperTheme: false + property int wallpaperInterval: 300 + property string wallpaperResize: "crop" + property int transitionFps: 60 + property string transitionType: "random" + property real transitionDuration: 1.1 + property string visualizerType: "radial" + property bool reverseDayMonth: false + property bool use12HourClock: false + property bool dimPanels: true + property real fontSizeMultiplier: 1.0 // Font size multiplier (1.0 = normal, 1.2 = 20% larger, 0.8 = 20% smaller) + property int taskbarIconSize: 24 // Taskbar icon button size in pixels (default: 32, smaller: 24, larger: 40) + property var pinnedExecs: [] // Added for AppLauncher pinned apps + + property bool showDock: true + property bool dockExclusive: false + property bool wifiEnabled: false + property bool bluetoothEnabled: false + property int recordingFrameRate: 60 + property string recordingQuality: "very_high" + property string recordingCodec: "h264" + property string audioCodec: "opus" + property bool showCursor: true + property string colorRange: "limited" + + // Monitor/Display Settings + property var barMonitors: [] // Array of monitor names to show the bar on + property var dockMonitors: [] // Array of monitor names to show the dock on + property var notificationMonitors: [] // Array of monitor names to show notifications on, "*" means all monitors + property var monitorScaleOverrides: { + + } // Map of monitor name -> scale override (e.g., 0.8..2.0). When set, Theme.scale() returns this value + } + } + + Connections { + target: settingAdapter + function onRandomWallpaperChanged() { + WallpaperManager.toggleRandomWallpaper() + } + function onWallpaperIntervalChanged() { + WallpaperManager.restartRandomWallpaperTimer() + } + function onWallpaperFolderChanged() { + WallpaperManager.loadWallpapers() + } + function onNotificationMonitorsChanged() {} + } } diff --git a/Services/Time.qml b/Services/Time.qml new file mode 100644 index 0000000..0d43a9e --- /dev/null +++ b/Services/Time.qml @@ -0,0 +1,49 @@ +pragma Singleton + +import Quickshell +import QtQuick +import qs.Services + +Singleton { + id: root + + property var date: new Date() + property string time: Settings.settings.use12HourClock ? Qt.formatDateTime( + date, + "h:mm AP") : Qt.formatDateTime( + date, "HH:mm") + property string dateString: { + let now = date + let dayName = now.toLocaleDateString(Qt.locale(), "ddd") + dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1) + let day = now.getDate() + let suffix + if (day > 3 && day < 21) + suffix = 'th' + else + switch (day % 10) { + case 1: + suffix = "st" + break + case 2: + suffix = "nd" + break + case 3: + suffix = "rd" + break + default: + suffix = "th" + } + let month = now.toLocaleDateString(Qt.locale(), "MMMM") + let year = now.toLocaleDateString(Qt.locale(), "yyyy") + return `${dayName}, ` + (Settings.settings.reverseDayMonth ? `${month} ${day}${suffix} ${year}` : `${day}${suffix} ${month} ${year}`) + } + + Timer { + interval: 1000 + repeat: true + running: true + + onTriggered: root.date = new Date() + } +} diff --git a/Theme/Style.qml b/Theme/Style.qml index a03783b..3a268b3 100644 --- a/Theme/Style.qml +++ b/Theme/Style.qml @@ -47,4 +47,6 @@ Singleton { property int barHeight: 36 property int baseWidgetHeight: 32 property int sliderWidth: 200 + + property int tooltipDelay: 300 } diff --git a/Widgets/NCalendar.qml b/Widgets/NCalendar.qml new file mode 100644 index 0000000..68ba5d1 --- /dev/null +++ b/Widgets/NCalendar.qml @@ -0,0 +1,204 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import qs.Services +import qs.Theme + +import "../Helpers/Holidays.js" as Holidays + +NPanel { + id: calendarOverlay + + readonly property real scaling: Scaling.scale(screen) + + Rectangle { + color: Theme.backgroundPrimary + radius: 12 + border.color: Theme.backgroundTertiary + border.width: 1 + width: 340 * scaling + height: 380 + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 4 * scaling + anchors.rightMargin: 4 * scaling + + // Prevent closing when clicking in the panel bg + MouseArea { + anchors.fill: parent + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + // Month/Year header with navigation + RowLayout { + Layout.fillWidth: true + spacing: 8 + + NIconButton { + icon: "chevron_left" + onClicked: function () { + let newDate = new Date(calendar.year, calendar.month - 1, 1) + calendar.year = newDate.getFullYear() + calendar.month = newDate.getMonth() + } + } + + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: calendar.title + color: Theme.textPrimary + opacity: 0.7 + font.pointSize: Style.fontSmall * scaling + font.family: Theme.fontFamily + font.bold: true + } + + NIconButton { + icon: "chevron_right" + onClicked: function () { + let newDate = new Date(calendar.year, calendar.month + 1, 1) + calendar.year = newDate.getFullYear() + calendar.month = newDate.getMonth() + } + } + } + + DayOfWeekRow { + Layout.fillWidth: true + spacing: 0 + Layout.leftMargin: 8 // Align with grid + Layout.rightMargin: 8 + + delegate: Text { + text: shortName + color: Theme.textPrimary + opacity: 0.8 + font.pointSize: Style.fontSmall * scaling + font.family: Theme.fontFamily + font.bold: true + horizontalAlignment: Text.AlignHCenter + width: 32 + } + } + + MonthGrid { + id: calendar + + property var holidays: [] + + // Fetch holidays when calendar is opened or month/year changes + function updateHolidays() { + Holidays.getHolidaysForMonth(calendar.year, calendar.month, + function (holidays) { + calendar.holidays = holidays + }) + } + + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.rightMargin: 8 + spacing: 0 + month: Time.date.getMonth() + year: Time.date.getFullYear() + onMonthChanged: updateHolidays() + onYearChanged: updateHolidays() + Component.onCompleted: updateHolidays() + + // Optionally, update when the panel becomes visible + Connections { + function onVisibleChanged() { + if (calendarOverlay.visible) { + calendar.month = Time.date.getMonth() + calendar.year = Time.date.getFullYear() + calendar.updateHolidays() + } + } + + target: calendarOverlay + } + + delegate: Rectangle { + property var holidayInfo: calendar.holidays.filter(function (h) { + var d = new Date(h.date) + return d.getDate() === model.day && d.getMonth() === model.month + && d.getFullYear() === model.year + }) + property bool isHoliday: holidayInfo.length > 0 + + width: 32 + height: 32 + radius: 8 + color: { + if (model.today) + return Theme.accentPrimary + + if (mouseArea2.containsMouse) + return Theme.backgroundTertiary + + return "transparent" + } + + // Holiday dot indicator + Rectangle { + visible: isHoliday + width: 4 + height: 4 + radius: 4 + color: Theme.accentTertiary + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 4 + anchors.rightMargin: 4 + z: 2 + } + + Text { + anchors.centerIn: parent + text: model.day + color: model.today ? Theme.onAccent : Theme.textPrimary + opacity: model.month === calendar.month ? (mouseArea2.containsMouse ? 1 : 0.7) : 0.3 + font.pointSize: Style.fontSmall * scaling + font.family: Theme.fontFamily + font.bold: model.today ? true : false + } + + MouseArea { + id: mouseArea2 + + anchors.fill: parent + hoverEnabled: true + onEntered: { + if (isHoliday) { + holidayTooltip.text = holidayInfo.map(function (h) { + return h.localName + (h.name !== h.localName ? " (" + h.name + ")" : "") + + (h.global ? " [Global]" : "") + }).join(", ") + holidayTooltip.target = parent; + holidayTooltip.show(); + } + } + onExited: holidayTooltip.hide() + } + + NTooltip { + id: holidayTooltip + text: "" + } + + Behavior on color { + ColorAnimation { + duration: 150 + } + } + } + } + } + } +} diff --git a/Widgets/NIconButton.qml b/Widgets/NIconButton.qml index 70d475b..bac4618 100644 --- a/Widgets/NIconButton.qml +++ b/Widgets/NIconButton.qml @@ -14,6 +14,7 @@ Rectangle { property bool hovering: false property var onEntered: function () {} property var onExited: function () {} + property var onClicked: function () {} implicitWidth: size implicitHeight: size @@ -45,5 +46,8 @@ Rectangle { hovering = false root.onExited() } + onClicked: { + root.onClicked() + } } } diff --git a/Widgets/NPanel.qml b/Widgets/NPanel.qml new file mode 100644 index 0000000..b13ddbc --- /dev/null +++ b/Widgets/NPanel.qml @@ -0,0 +1,46 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Services +import qs.Theme + +PanelWindow { + id: outerPanel + + readonly property real scaling: Scaling.scale(screen) + property bool showOverlay: Settings.settings.dimPanels + property int topMargin: Style.barHeight * scaling + property color overlayColor: showOverlay ? Theme.overlay : "transparent" + + function dismiss() { + visible = false + } + + function show() { + visible = true + } + + implicitWidth: screen.width + implicitHeight: screen.height + color: visible ? overlayColor : "transparent" + visible: false + WlrLayershell.exclusionMode: ExclusionMode.Ignore + screen: (typeof modelData !== 'undefined' ? modelData : null) + anchors.top: true + anchors.left: true + anchors.right: true + anchors.bottom: true + margins.top: topMargin + + MouseArea { + anchors.fill: parent + onClicked: outerPanel.dismiss() + } + + Behavior on color { + ColorAnimation { + duration: 350 + easing.type: Easing.InOutCubic + } + } +} diff --git a/Widgets/NTooltip.qml b/Widgets/NTooltip.qml index 8a8e101..231fbb6 100644 --- a/Widgets/NTooltip.qml +++ b/Widgets/NTooltip.qml @@ -9,7 +9,7 @@ Window { property bool isVisible: false property string text: "Placeholder" property Item target: null - property int delay: 300 + property int delay: Style.tooltipDelay property bool positionAbove: false flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint