import QtQuick import Quickshell import qs.Commons import qs.Ui import "Model.js" as Model // The clock's calendar popup: a month grid with ISO week numbers, built to // sit beside the weather panel — same hero-over-detail composition, same // spacing scale, same small-caps labels. // // The grid is a read-out rather than a picker: today is the only marked // day, and the only thing that moves is which month is on screen — // chevrons, the scroll wheel, and the arrow keys all step it. // // BarWidget.qml owns the bar label and hands this panel the button to // anchor against. Panel { id: root moduleName: "omarchy.clock" ipcTarget: "omarchy.clock" manageIpc: false property var anchorItem: null // The bar tracks the widget mounted in its slot — BarWidget.qml — not this // nested panel. Everything the bar identifies a panel by has to be that // widget: the popout coordinator (and with it the open-panel dot under the // pill) compares against `slot.activeItem`, and switchPanelFrom looks the // slot up the same way. property var hostWidget: null readonly property var barIdentity: hostWidget || root // ---- Today. SystemClock keeps this honest across midnight so the // highlight rolls over without the panel being reopened. property date today: new Date() readonly property string todayKey: Model.keyForDate(today) // The month on screen. Stepping moves this and nothing else: the grid is // a read-out, not a picker, so there is no per-day cursor to keep in sync. property int viewYear: today.getFullYear() property int viewMonth: today.getMonth() readonly property date viewDate: new Date(viewYear, viewMonth, 1) readonly property bool viewingCurrentMonth: viewYear === today.getFullYear() && viewMonth === today.getMonth() // Pinned to today, not to the month being browsed — stepping through the // calendar does not change how much of the year is gone. readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate()) readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate()) // Unset falls through to the locale's own first day, so a fresh install // starts out matching the rest of the desktop rather than a hardcoded // convention. Clicking the grid's "W" heading writes the choice back to // shell.json. readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek) readonly property string nextWeekStartLabel: Qt.locale().dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat) readonly property var weekdays: Model.weekdayOrder(weekStart) readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey) // Guarded so the widget renders before the bar is injected (the bar-widget // contract instantiates it bare). readonly property color contentForeground: bar ? bar.foreground : Color.foreground readonly property string contentFontFamily: bar ? bar.fontFamily : Style.font.family readonly property int cellWidth: Style.space(52) readonly property int cellHeight: Style.space(34) readonly property int cellSpacing: Style.space(2) readonly property int weekColumnWidth: Style.space(32) readonly property int gutterWidth: Style.space(14) function open() { setCenterHoverRevealSuppressed(true) refresh() root.controller.show() } function close() { // Not when another panel is taking over: it has already set the shared // flag for itself, and clearing it here would leave the incoming panel // open with the center indicators revealed behind it. if (!root.popoutSwitchClosing) setCenterHoverRevealSuppressed(false) root.controller.hide() } function toggle() { if (root.opened) root.close() else root.open() } function switchPanel(direction) { if (root.bar && typeof root.bar.switchPanelFrom === "function") return root.bar.switchPanelFrom(root.barIdentity, direction) return false } // Summoning by hotkey moves no pointer, so a hover the bar was still // holding must not keep the center indicators revealed behind the panel. function setCenterHoverRevealSuppressed(value) { if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value } function refresh() { root.today = new Date() root.goToToday() } function goToToday() { root.viewYear = today.getFullYear() root.viewMonth = today.getMonth() } function moveMonth(delta) { var next = Model.stepMonth(viewYear, viewMonth, delta) root.viewYear = next.year root.viewMonth = next.month } function moveYear(delta) { moveMonth(delta * 12) } function setWeekStart(day) { var next = Model.normalizedWeekStart(day, root.weekStart) if (next === root.weekStart) return var entry = { id: root.moduleName } for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key] entry.weekStartDay = Model.weekStartSettingName(next) // Applied locally first so the grid reflows on the click itself; the // shell.json write comes back through the bar as the same value. With // no writable entry (the widget is not in the layout) it stays a // session-only preference rather than doing nothing. root.settings = entry // The host widget builds its own entry when the label format is cycled. // Until shell.json round-trips it would be working from a copy without // this key, and would write the week start straight back out. if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function") root.bar.shell.updateEntryInline(root.moduleName, entry) } function toggleWeekStart() { setWeekStart(Model.toggledWeekStart(root.weekStart)) } // Locale short day names, trimmed of the trailing period some locales // carry ("man." -> "MAN") so the header row stays a clean band of caps. function weekdayLabel(weekday) { return String(Qt.locale().dayName(weekday, Locale.ShortFormat)).replace(/\.$/, "").toUpperCase() } SystemClock { id: clock precision: SystemClock.Minutes onDateChanged: { if (Model.keyForDate(clock.date) === String(root.todayKey)) return var followToday = root.viewingCurrentMonth root.today = clock.date if (followToday) root.goToToday() } } KeyboardPanel { id: panel anchorItem: root.anchorItem owner: root.barIdentity bar: root.bar open: root.opened centerOnBar: true focusTarget: keyCatcher contentWidth: panel.fittedContentWidth(Style.space(560)) contentHeight: panel.fittedContentHeight(calendarColumn.implicitHeight) PanelKeyCatcher { id: keyCatcher anchors.fill: parent onMoveRequested: function(dx, dy) { if (dx !== 0) root.moveMonth(dx) if (dy !== 0) root.moveYear(dy) } onActivateRequested: root.goToToday() onCloseRequested: root.close() onTabRequested: function(direction) { root.switchPanel(direction) } onTextKey: function(t) { if (t === "[") root.moveMonth(-1) else if (t === "]") root.moveMonth(1) else if (t === "{") root.moveYear(-1) else if (t === "}") root.moveYear(1) else if (t === "t" || t === "T") root.goToToday() else if (t === "w" || t === "W") root.toggleWeekStart() } Flickable { id: calendarScroll anchors.fill: parent contentWidth: calendarColumn.width contentHeight: calendarColumn.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds interactive: contentHeight > height || contentWidth > width Column { id: calendarColumn // Never narrower than the grid. The popup width is capped to what // the screen allows, and a fixed seven-column grid would otherwise // lose its last days off the edge instead of scrolling. width: Math.max(calendarScroll.width, gridColumn.width) spacing: Style.space(8) // ---- Hero: today, centered. Once the view has stepped back // it is also the way home — clicking the date you are // looking for beats hunting for a reset button. Item { width: parent.width height: heroRow.height Row { id: heroRow anchors.horizontalCenter: parent.horizontalCenter spacing: Style.space(22) Text { // Baseline-aligned, not center-aligned: "July 26" carries a // descender, so centering the two boxes leaves the icon // sitting visibly low against the digits. anchors.baseline: heroDate.baseline text: "󰃭" color: heroMouse.containsMouse ? Style.hoverStateColor(root.contentForeground, Color.accent) : root.contentForeground font.family: root.contentFontFamily // Decorative, and deliberately outside the Style.font.* // scale. Sized so the glyph reads at the cap height of the // date beside it rather than towering over it. font.pixelSize: 48 } Text { id: heroDate anchors.verticalCenter: parent.verticalCenter text: Qt.formatDate(root.today, "MMMM d") color: heroMouse.containsMouse ? Style.hoverStateColor(root.contentForeground, Color.accent) : root.contentForeground font.family: root.contentFontFamily font.pixelSize: 52 font.bold: true } } MouseArea { id: heroMouse x: heroRow.x y: heroRow.y width: heroRow.width height: heroRow.height enabled: !root.viewingCurrentMonth hoverEnabled: enabled cursorShape: Qt.PointingHandCursor onClicked: root.goToToday() PanelToolTip { visible: heroMouse.containsMouse text: "Back to today" fontFamily: root.contentFontFamily } } } // ---- Year progress, doubling as the rule under the hero: // a plain hairline said nothing, and whole days done // over days in the year says the same thing louder. Item { width: parent.width height: yearBlock.y + yearBlock.height Item { id: yearBlock y: Style.space(6) anchors.horizontalCenter: parent.horizontalCenter width: gridColumn.width height: Math.max(yearLabel.implicitHeight, Style.space(10)) Text { id: yearLabel anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.today.getFullYear() color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall font.letterSpacing: 1 } Text { id: yearPercent anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.yearDonePercent + "%" color: root.contentForeground font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall } Rectangle { id: yearTrack anchors.left: yearLabel.right anchors.right: yearPercent.left anchors.leftMargin: Style.space(12) anchors.rightMargin: Style.space(12) anchors.verticalCenter: parent.verticalCenter height: Style.space(6) radius: Style.cornerRadius > 0 ? height / 2 : 0 color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12) Rectangle { width: Math.round(parent.width * root.yearDone) height: parent.height radius: parent.radius color: Style.selectedStateColor(root.contentForeground, Color.accent) Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } } } } } // ---- Month grid: week numbers down a gutter on the left, then // the seven day columns. Always six rows, so the popup is // exactly as tall in February as it is in August. Item { width: parent.width height: gridColumn.y + gridColumn.height WheelHandler { onWheel: function(event) { // Horizontal wheels and touchpad side-scrolls report y === 0; // without this they would every one read as "next month". if (event.angleDelta.y === 0) return root.moveMonth(event.angleDelta.y > 0 ? -1 : 1) } } Column { id: gridColumn // The meter above is a solid rule; the grid needs room to // read as its own block rather than hanging off it. y: Style.space(18) anchors.horizontalCenter: parent.horizontalCenter spacing: Style.space(3) Row { id: headerRow spacing: root.cellSpacing // The week-number heading doubles as the week-start toggle. // It is the one control in the panel whose meaning is not // self-evident, so it carries a tooltip naming the day the // click will switch to. Rectangle { width: root.weekColumnWidth height: Style.space(16) radius: Style.cornerRadius color: weekStartMouse.containsMouse ? Style.hoverFillFor(root.contentForeground, Color.accent) : "transparent" Text { anchors.centerIn: parent text: "W" color: weekStartMouse.containsMouse ? Style.hoverStateColor(root.contentForeground, Color.accent) : Qt.darker(root.contentForeground, 1.9) font.family: root.contentFontFamily font.pixelSize: Style.font.caption font.letterSpacing: 1 font.bold: true } MouseArea { id: weekStartMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.toggleWeekStart() } PanelToolTip { visible: weekStartMouse.containsMouse text: "Start weeks on " + root.nextWeekStartLabel fontFamily: root.contentFontFamily } } Item { width: root.gutterWidth height: Style.space(16) } Repeater { model: root.weekdays Text { required property var modelData width: root.cellWidth height: Style.space(16) horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter text: root.weekdayLabel(modelData) color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.caption font.letterSpacing: 1 font.bold: true } } } Repeater { model: root.weeks Row { required property var modelData spacing: root.cellSpacing Text { width: root.weekColumnWidth height: root.cellHeight horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter text: modelData.week color: Qt.darker(root.contentForeground, 1.9) font.family: root.contentFontFamily font.pixelSize: Style.font.caption } Item { width: root.gutterWidth height: root.cellHeight } Repeater { model: modelData.days Rectangle { required property var modelData width: root.cellWidth height: root.cellHeight radius: Style.cornerRadius // Today is outlined, not filled: a lit-up block shouts // over a grid this quiet. color: "transparent" border.width: modelData.today ? Style.spacing.hairline : 0 border.color: Style.normalBorderFor(root.contentForeground, Color.accent) Text { anchors.centerIn: parent text: modelData.day color: modelData.inMonth ? (modelData.weekend ? Qt.darker(root.contentForeground, 1.45) : root.contentForeground) : Qt.darker(root.contentForeground, 2.2) font.family: root.contentFontFamily font.pixelSize: Style.font.body font.bold: modelData.today } } } } } } // Hairline down the week-number gutter, drawn only beside the // day rows so it does not cut through the header band. Rectangle { x: gridColumn.x + root.weekColumnWidth + root.cellSpacing + Math.round((root.gutterWidth - width) / 2) y: gridColumn.y + headerRow.height + gridColumn.spacing width: Style.spacing.hairline height: gridColumn.height - headerRow.height - gridColumn.spacing color: root.contentForeground opacity: 0.1 } } // ---- Month stepping, spanning the grid it drives. The chevrons // sit on the grid's outer bounds, the same edges the year // rail above uses, so the row reads as the panel's other // full-width rail instead of a cluster floating in space. // The label is centered and fixed-width, so it holds still // from "MAY" to "SEPTEMBER". Item { width: parent.width height: monthNav.height Item { id: monthNav anchors.horizontalCenter: parent.horizontalCenter width: gridColumn.width height: monthLabel.implicitHeight + Style.space(10) Text { id: monthLabel anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter // Fixed width so the chevrons hold still between a // "MAY 2026" and a "SEPTEMBER 2026". width: Style.space(130) horizontalAlignment: Text.AlignHCenter text: Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase() color: Qt.darker(root.contentForeground, 1.4) font.family: root.contentFontFamily font.pixelSize: Style.font.body font.letterSpacing: 1 } PanelActionButton { // Pulled out by the button's own padding so the glyph, not // its hit box, lines up with the "2026" on the year rail. anchors.left: parent.left anchors.leftMargin: -Style.space(8) anchors.verticalCenter: parent.verticalCenter iconText: "󰅁" tooltipText: "Previous month" foreground: root.contentForeground fontFamily: root.contentFontFamily onClicked: root.moveMonth(-1) } PanelActionButton { anchors.right: parent.right anchors.rightMargin: -Style.space(8) anchors.verticalCenter: parent.verticalCenter iconText: "󰅂" tooltipText: "Next month" foreground: root.contentForeground fontFamily: root.contentFontFamily onClicked: root.moveMonth(1) } } } } } } } }