Merge pull request #6390 from basecamp/clock-calendar
Add a calendar popup to the clock
This commit is contained in:
@@ -98,6 +98,19 @@ def resolve_relative(match):
|
||||
|
||||
text = re.sub(r'Qt\.resolvedUrl\("([^"]+)"\)', resolve_relative, text)
|
||||
|
||||
|
||||
# A widget that imports a sibling JS module (the clock's Model.js) clones into
|
||||
# a directory that does not have it. Point the import back at the bundled file,
|
||||
# the same way Qt.resolvedUrl() references above are rewritten.
|
||||
def resolve_js_import(match):
|
||||
rel = match.group(1)
|
||||
if rel.startswith("/") or "://" in rel:
|
||||
return match.group(0)
|
||||
resolved = (source_path.parent / rel).resolve()
|
||||
return 'import "' + resolved.as_uri() + '"'
|
||||
|
||||
text = re.sub(r'import\s+"([^"]+\.js)"', resolve_js_import, text)
|
||||
|
||||
# Indicators dynamically loads sibling indicator components via string
|
||||
# concatenation, so the simple Qt.resolvedUrl("literal") rewrite above cannot
|
||||
# see it. Point the clone back at Omarchy's bundled indicator directory.
|
||||
|
||||
@@ -77,6 +77,7 @@ o.bind("SUPER + CTRL + ALT + W", "Toggle weather", "omarchy-notification-weather
|
||||
o.bind("SUPER + CTRL + A", "Audio", "omarchy-shell shell toggle omarchy.audio")
|
||||
o.bind("SUPER + CTRL + B", "Bluetooth", "omarchy-shell shell toggle omarchy.bluetooth")
|
||||
o.bind("SUPER + CTRL + D", "Display", "omarchy-shell shell toggle omarchy.monitor")
|
||||
o.bind("SUPER + CTRL + ALT + D", "Calendar", "omarchy-shell shell toggle omarchy.clock")
|
||||
o.bind("SUPER + CTRL + W", "Network", "omarchy-shell shell toggle omarchy.network")
|
||||
o.bind("SUPER + CTRL + P", "Power", "omarchy-shell shell toggle omarchy.power")
|
||||
o.bind("SUPER + CTRL + T", "Activity", { tui = "btop" })
|
||||
|
||||
@@ -59,6 +59,9 @@ Item {
|
||||
readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin)
|
||||
readonly property real scaledVerticalPadding: Style.spaceReal(verticalPadding)
|
||||
readonly property bool tooltipHovered: visible && interactive && !concealed && mouseArea.containsMouse
|
||||
// Width of the painted label, for bar chrome that wants to line up with the
|
||||
// text rather than with the slot it sits in. Zero on icon-only buttons.
|
||||
readonly property real labelWidth: label.visible ? label.implicitWidth : 0
|
||||
|
||||
visible: hasVisualContent || keepSpace
|
||||
opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1)
|
||||
|
||||
@@ -22,6 +22,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
||||
| Notifications | `omarchy.notifications` | `service` | `notifications/Service.qml` |
|
||||
| Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` |
|
||||
| Bluetooth | `omarchy.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` |
|
||||
| Clock | `omarchy.clock` | `bar-widget` | `panels/clock/BarWidget.qml` |
|
||||
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
|
||||
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
||||
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
||||
@@ -37,7 +38,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
||||
| Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` |
|
||||
|
||||
First-party bar-only widgets also carry manifests next to their QML files,
|
||||
e.g. `bar/widgets/Clock.manifest.json`. Rich popup widgets live in their
|
||||
e.g. `bar/widgets/Workspaces.manifest.json`. Rich popup widgets live in their
|
||||
own plugin directories, each with its own `manifest.json`.
|
||||
|
||||
## Bar
|
||||
|
||||
@@ -406,15 +406,19 @@ Item {
|
||||
function findPanelWidget(pluginId) {
|
||||
var id = String(pluginId || "")
|
||||
if (!id) return null
|
||||
var candidates = []
|
||||
for (var i = 0; i < moduleSlots.length; i++) {
|
||||
var slot = moduleSlots[i]
|
||||
if (!slot || !slot.activeItem) continue
|
||||
if (slot.moduleName !== id) continue
|
||||
var item = slot.activeItem
|
||||
if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue
|
||||
return item
|
||||
candidates.push(slot)
|
||||
}
|
||||
return null
|
||||
// Anchored center modules are mounted twice; only the drawn copy can
|
||||
// anchor a popup or carry the open-panel mark. See BarModel.pickDrawnSlot.
|
||||
var chosen = BarModel.pickDrawnSlot(candidates)
|
||||
return chosen ? chosen.activeItem : null
|
||||
}
|
||||
|
||||
function summonBarWidget(pluginId) {
|
||||
@@ -1317,6 +1321,12 @@ Item {
|
||||
property string region: ""
|
||||
|
||||
visible: entries.length > 0
|
||||
// A hidden list must not build its modules. The center section declares
|
||||
// both an anchored and an unanchored arrangement and shows whichever
|
||||
// fits, so leaving the other one loaded mounts every center module
|
||||
// twice — two IPC handlers registered for the same target, two clocks
|
||||
// ticking, two of every timer and fetch behind them.
|
||||
active: visible && entries.length > 0
|
||||
sourceComponent: root.vertical ? verticalModuleList : horizontalModuleList
|
||||
width: item ? item.implicitWidth : 0
|
||||
height: item ? item.implicitHeight : 0
|
||||
@@ -1386,6 +1396,16 @@ Item {
|
||||
readonly property bool hovered: moduleHover.hovered
|
||||
readonly property bool dragSource: root.barDragSource === slot
|
||||
readonly property bool panelOpen: root.activePopout === slot.activeItem
|
||||
// Modules bigger than the mark they want (a text label in a padded slot,
|
||||
// a multi-line stack on a vertical bar) can say how long the open-panel
|
||||
// dot should be along the bar, so it tracks what the module paints
|
||||
// instead of a fraction of whatever slot it happens to fill.
|
||||
readonly property real panelIndicatorExtent: {
|
||||
var key = root.vertical ? "openPanelIndicatorHeight" : "openPanelIndicatorWidth"
|
||||
var hint = activeItem && key in activeItem ? activeItem[key] : undefined
|
||||
if (hint !== undefined && hint !== null && hint > 0) return Math.round(hint)
|
||||
return Math.max(Style.space(10), Math.round((root.vertical ? slot.height : slot.width) * 0.55))
|
||||
}
|
||||
implicitWidth: activeItem && activeItem.visible ? (root.vertical ? root.barSize : activeItem.implicitWidth) : 0
|
||||
implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0
|
||||
width: implicitWidth
|
||||
@@ -1455,8 +1475,12 @@ Item {
|
||||
opacity: slot.panelOpen && !slot.dragSource ? 0.9 : 0
|
||||
color: Color.accent
|
||||
radius: Math.min(width, height) / 2
|
||||
width: root.vertical ? Style.space(2) : Math.max(Style.space(10), Math.round(parent.width * 0.55))
|
||||
height: root.vertical ? Math.max(Style.space(10), Math.round(parent.height * 0.55)) : Style.space(2)
|
||||
width: root.vertical ? Style.space(2) : slot.panelIndicatorExtent
|
||||
height: root.vertical ? slot.panelIndicatorExtent : Style.space(2)
|
||||
// The mark sits on the module's inner edge — the one facing the
|
||||
// desktop — so it underlines a top bar, overlines a bottom one, and
|
||||
// points inward from a left or right one. It reads as pointing at the
|
||||
// panel that opens on that side.
|
||||
x: root.vertical
|
||||
? (root.position === "left" ? parent.width - width - inset : inset)
|
||||
: Math.round((parent.width - width) / 2)
|
||||
|
||||
@@ -96,8 +96,32 @@ function customModulePath(entry, home, configDir) {
|
||||
return source
|
||||
}
|
||||
|
||||
// A center module is mounted twice once an anchor is set: the copy that is
|
||||
// actually drawn, and a zero-size placeholder holding its place in the flow
|
||||
// beside the anchor. Panel routing has to pick the drawn one — it is the only
|
||||
// one that can anchor a popup, carry the open-panel mark, or be found again
|
||||
// by switchPanelFrom — and fall back to the placeholder only when nothing is
|
||||
// on screen. The order the two are registered in is not stable across a live
|
||||
// bar reconfiguration, so picking the first match is not good enough.
|
||||
function isDrawnSlot(slot) {
|
||||
return !!slot && slot.visible === true && slot.width > 0 && slot.height > 0
|
||||
}
|
||||
|
||||
function pickDrawnSlot(slots) {
|
||||
var placeholder = null
|
||||
var list = slots || []
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (!list[i]) continue
|
||||
if (isDrawnSlot(list[i])) return list[i]
|
||||
if (!placeholder) placeholder = list[i]
|
||||
}
|
||||
return placeholder
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
isDrawnSlot: isDrawnSlot,
|
||||
pickDrawnSlot: pickDrawnSlot,
|
||||
normalizePosition: normalizePosition,
|
||||
entrySettings: entrySettings,
|
||||
entryId: entryId,
|
||||
|
||||
@@ -56,7 +56,7 @@ Example `shell.json` (bar subtree only shown):
|
||||
|---|---|---|
|
||||
| `omarchy.menu` | Omarchy menu launcher | left = menu · right = terminal |
|
||||
| `omarchy.workspaces` | Hyprland workspace switcher | left = focus workspace |
|
||||
| `omarchy.clock` | Date/time label | left = alternate format · right = timezone selector |
|
||||
| `omarchy.clock` | Date/time label + popup with a month grid, ISO week numbers, and month stepping | left = popup · right = cycle label format · middle = timezone selector |
|
||||
| `omarchy.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup |
|
||||
| `omarchy.indicators` | Manual state indicators | left = indicator action |
|
||||
| `omarchy.system-update` | Available update indicator | left = update |
|
||||
@@ -166,7 +166,7 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o
|
||||
- `bar.requestPopout(owner)` / `bar.releasePopout(owner)` — one-popup-at-a-time coordinator
|
||||
|
||||
First-party bar widgets are manifest-backed just like third-party widgets.
|
||||
Simple widgets carry sibling manifests such as `widgets/Clock.manifest.json`;
|
||||
Simple widgets carry sibling manifests such as `widgets/Workspaces.manifest.json`;
|
||||
richer popup plugins live in feature directories such as `../panels/audio/`,
|
||||
`../panels/network/`, and `../model-usage/`; and feature plugins such as
|
||||
`omarchy.menu` and `omarchy.media` declare their bar-widget entry points in their own
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.clock"
|
||||
|
||||
property bool alt: false
|
||||
property date displayDate: clock.date
|
||||
|
||||
readonly property string activeFormat: alt
|
||||
? (vertical ? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy") : setting("formatAlt", "d MMMM 'W'ww yyyy"))
|
||||
: (vertical ? setting("verticalFormat", "HH\n—\nmm") : setting("format", "dddd HH:mm"))
|
||||
readonly property string displayText: formatted(displayDate)
|
||||
readonly property var verticalLines: displayText.split("\n")
|
||||
|
||||
function refresh() {
|
||||
displayDate = new Date()
|
||||
}
|
||||
|
||||
function isoWeek(date) {
|
||||
var d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||||
var day = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - day)
|
||||
var yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
||||
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7)
|
||||
}
|
||||
|
||||
function isoWeekLiteral(date) {
|
||||
var week = isoWeek(date)
|
||||
return (week < 10 ? "0" : "") + week
|
||||
}
|
||||
|
||||
function formatted(date) {
|
||||
return Qt.formatDateTime(date, activeFormat.replace(/ww/g, isoWeekLiteral(date)))
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Minutes
|
||||
onDateChanged: root.displayDate = date
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "omarchy.clock"
|
||||
function refresh(): void { root.broadcast("refresh") }
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.vertical ? "" : root.displayText
|
||||
labelVisible: !root.vertical
|
||||
hasVisualContent: root.vertical ? root.verticalLines.length > 0 : text !== ""
|
||||
fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1
|
||||
horizontalMargin: 8.75
|
||||
verticalPadding: 8.75
|
||||
onPressed: function(button) {
|
||||
if (!root.bar) return
|
||||
if (button === Qt.RightButton) root.bar.run("omarchy-menu-timezone")
|
||||
else root.alt = !root.alt
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: root.vertical
|
||||
anchors.fill: parent
|
||||
|
||||
Repeater {
|
||||
model: root.verticalLines
|
||||
|
||||
OpticalGlyph {
|
||||
required property string modelData
|
||||
width: button.width
|
||||
height: Style.bar.iconSlot
|
||||
text: modelData
|
||||
fontFamily: button.fontFamily
|
||||
fontSize: modelData.length > 3
|
||||
? button.fontSize * 0.9
|
||||
: button.fontSize
|
||||
color: button.foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
|
||||
// Date/time label for the bar, and the host for the calendar popup.
|
||||
//
|
||||
// Left click reveals the calendar — asking "what is the date?" is what a
|
||||
// click on a clock means — right click walks the common label formats, and
|
||||
// middle click opens the timezone picker.
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.clock"
|
||||
|
||||
property date displayDate: clock.date
|
||||
|
||||
readonly property string configuredFormat: vertical
|
||||
? setting("verticalFormat", "HH\n—\nmm")
|
||||
: setting("format", "dddd HH:mm")
|
||||
readonly property string configuredAltFormat: vertical
|
||||
? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy")
|
||||
: setting("formatAlt", "d MMMM 'W'ww yyyy")
|
||||
|
||||
readonly property var formatRing: Model.clockFormatRing(configuredFormat, configuredAltFormat, Model.clockFormats(vertical))
|
||||
|
||||
// What the bar shows is what shell.json stores, so a cycled format is the
|
||||
// format from then on rather than something that reverts on restart.
|
||||
readonly property string activeFormat: configuredFormat
|
||||
readonly property string displayText: formatted(displayDate)
|
||||
readonly property var verticalLines: displayText.split("\n")
|
||||
|
||||
function refresh() {
|
||||
displayDate = new Date()
|
||||
if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh()
|
||||
}
|
||||
|
||||
function cycleFormat() {
|
||||
var current = String(configuredFormat)
|
||||
var next = Model.nextClockFormat(formatRing, current)
|
||||
if (next === "" || next === current) return
|
||||
|
||||
var entry = { id: root.moduleName }
|
||||
for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key]
|
||||
entry[vertical ? "verticalFormat" : "format"] = next
|
||||
|
||||
// Applied locally first so the label changes on the click itself; the
|
||||
// shell.json write comes back through the bar as the same value.
|
||||
root.settings = entry
|
||||
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
|
||||
root.bar.shell.updateEntryInline(root.moduleName, entry)
|
||||
}
|
||||
|
||||
function formatted(date) {
|
||||
return Qt.formatDateTime(date, activeFormat.replace(/ww/g, Model.isoWeekLiteral(date.getFullYear(), date.getMonth(), date.getDate())))
|
||||
}
|
||||
|
||||
// ---- Calendar popup. Shape contract for shell.summon/hide/toggle
|
||||
// routing: Bar.findPanelWidget requires open/close/opened on the
|
||||
// bar-widget root.
|
||||
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
|
||||
|
||||
function open() {
|
||||
if (panelLoader.item) panelLoader.item.open()
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (panelLoader.item) panelLoader.item.close()
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panelLoader.item) panelLoader.item.toggle()
|
||||
}
|
||||
|
||||
// The clock fills more slot than it paints a mark for, at both
|
||||
// orientations: horizontally it is a text label in a padded slot, so the
|
||||
// dot takes the label width; vertically it is a stack of icon-sized lines,
|
||||
// so the dot takes one line — the same mark every icon widget gets, rather
|
||||
// than a rule running the height of the whole stack.
|
||||
readonly property real openPanelIndicatorWidth: button.labelWidth
|
||||
readonly property real openPanelIndicatorHeight: Math.max(Style.space(10), Math.round(Style.bar.iconSlot * 0.55))
|
||||
|
||||
// Forwarded so this widget can stand in for the panel as the bar's popout
|
||||
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
|
||||
// KeyboardPanel reads popoutSwitchClosing back off its owner.
|
||||
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
|
||||
|
||||
function closeForPopoutSwitch() {
|
||||
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
|
||||
}
|
||||
|
||||
function injectPanel() {
|
||||
var target = panelLoader.item
|
||||
if (!target) return
|
||||
if ("bar" in target) target.bar = root.bar
|
||||
if ("settings" in target) target.settings = root.settings
|
||||
if ("anchorItem" in target) target.anchorItem = button
|
||||
if ("hostWidget" in target) target.hostWidget = root
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
onBarChanged: injectPanel()
|
||||
onSettingsChanged: injectPanel()
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Minutes
|
||||
onDateChanged: root.displayDate = date
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: panelLoader
|
||||
active: true
|
||||
source: Qt.resolvedUrl("Panel.qml")
|
||||
visible: false
|
||||
onLoaded: {
|
||||
root.injectPanel()
|
||||
Qt.callLater(root.injectPanel)
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "omarchy.clock"
|
||||
|
||||
function refresh(): void { root.broadcast("refresh") }
|
||||
function cycleFormat(): void { root.cycleFormat() }
|
||||
function open(): void { root.open() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.open() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.togglePanel() }
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.vertical ? "" : root.displayText
|
||||
labelVisible: !root.vertical
|
||||
hasVisualContent: root.vertical ? root.verticalLines.length > 0 : text !== ""
|
||||
fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1
|
||||
horizontalMargin: 8.75
|
||||
verticalPadding: 8.75
|
||||
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.RightButton) root.cycleFormat()
|
||||
else if (b === Qt.MiddleButton) { if (root.bar) root.bar.run("omarchy-menu-timezone") }
|
||||
else root.togglePanel()
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: root.vertical
|
||||
anchors.fill: parent
|
||||
|
||||
Repeater {
|
||||
model: root.verticalLines
|
||||
|
||||
OpticalGlyph {
|
||||
required property string modelData
|
||||
width: button.width
|
||||
height: Style.bar.iconSlot
|
||||
text: modelData
|
||||
fontFamily: button.fontFamily
|
||||
fontSize: modelData.length > 3
|
||||
? button.fontSize * 0.9
|
||||
: button.fontSize
|
||||
color: button.foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Pure date and format math for the clock widget and its calendar panel.
|
||||
// Everything here is locale- and Qt-free so it can be unit tested under node
|
||||
// (test/shell.d/clock-test.sh); the QML owns month/weekday naming through
|
||||
// Qt.locale().
|
||||
|
||||
var MS_PER_DAY = 86400000
|
||||
|
||||
// Weekday indices match both JS Date.getDay() and QML's Locale.Sunday…
|
||||
// Locale.Saturday, so a locale's firstDayOfWeek can be passed straight in.
|
||||
var WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]
|
||||
|
||||
// ---- Bar label formats. Right-clicking the clock walks these in order and
|
||||
// writes the result back to shell.json, so the label the bar shows and
|
||||
// the format the config stores are always the same thing.
|
||||
var CLOCK_FORMATS = [
|
||||
"dddd HH:mm",
|
||||
"HH:mm",
|
||||
"ddd d MMM HH:mm",
|
||||
"d MMMM 'W'ww yyyy",
|
||||
"yyyy-MM-dd HH:mm"
|
||||
]
|
||||
|
||||
// Vertical bars have room for a few stacked lines and nothing else, so the
|
||||
// ring stays short.
|
||||
var VERTICAL_CLOCK_FORMATS = [
|
||||
"HH\n—\nmm",
|
||||
"dd\nMMM\n'W'ww\n''yy",
|
||||
"HH\nmm"
|
||||
]
|
||||
|
||||
function clockFormats(vertical) {
|
||||
return vertical ? VERTICAL_CLOCK_FORMATS.slice() : CLOCK_FORMATS.slice()
|
||||
}
|
||||
|
||||
// The presets in a fixed order, plus the configured alternate and current
|
||||
// format when they are something else. The order must not depend on which
|
||||
// entry is current: cycling writes the result back to shell.json, and a ring
|
||||
// that reshuffled itself around the current value would bounce between two
|
||||
// entries instead of walking.
|
||||
function clockFormatRing(configured, configuredAlt, presets) {
|
||||
var ring = []
|
||||
var candidates = (presets || []).concat([configuredAlt, configured])
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var format = String(candidates[i] === undefined || candidates[i] === null ? "" : candidates[i])
|
||||
if (format === "" || ring.indexOf(format) !== -1) continue
|
||||
ring.push(format)
|
||||
}
|
||||
return ring.length > 0 ? ring : ["HH:mm"]
|
||||
}
|
||||
|
||||
// Next entry after `current`. An unknown current format (a hand-written one
|
||||
// that is not in the ring) starts the walk at the top.
|
||||
function nextClockFormat(ring, current) {
|
||||
if (!ring || ring.length === 0) return ""
|
||||
var index = ring.indexOf(String(current === undefined || current === null ? "" : current))
|
||||
return ring[(index + 1) % ring.length]
|
||||
}
|
||||
|
||||
// Two-digit ISO week, substituted into a format's 'ww' token before Qt
|
||||
// formats it -- Qt has no ISO week specifier of its own.
|
||||
function isoWeekLiteral(year, month, day) {
|
||||
return pad2(isoWeek(year, month, day))
|
||||
}
|
||||
|
||||
function pad2(value) {
|
||||
var n = Number(value)
|
||||
return (n < 10 ? "0" : "") + n
|
||||
}
|
||||
|
||||
// Stable "yyyy-MM-dd" identity for a day, so a grid cell can be compared
|
||||
// against today without dragging Date objects through bindings.
|
||||
function dateKey(year, month, day) {
|
||||
return year + "-" + pad2(Number(month) + 1) + "-" + pad2(day)
|
||||
}
|
||||
|
||||
function keyForDate(date) {
|
||||
return dateKey(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
}
|
||||
|
||||
function coerceWeekStart(value) {
|
||||
if (value === undefined || value === null) return null
|
||||
if (typeof value === "number")
|
||||
return isFinite(value) ? ((Math.round(value) % 7) + 7) % 7 : null
|
||||
|
||||
var text = String(value).replace(/^\s+|\s+$/g, "").toLowerCase()
|
||||
if (text === "") return null
|
||||
|
||||
for (var i = 0; i < WEEKDAY_NAMES.length; i++)
|
||||
if (WEEKDAY_NAMES[i] === text || WEEKDAY_NAMES[i].substr(0, 3) === text) return i
|
||||
|
||||
var parsed = parseInt(text, 10)
|
||||
return isFinite(parsed) ? ((parsed % 7) + 7) % 7 : null
|
||||
}
|
||||
|
||||
// Configured week start, falling back to the locale's own first day when
|
||||
// the setting is missing or nonsense.
|
||||
function normalizedWeekStart(value, fallback) {
|
||||
var configured = coerceWeekStart(value)
|
||||
if (configured !== null) return configured
|
||||
var fallbackStart = coerceWeekStart(fallback)
|
||||
return fallbackStart === null ? 1 : fallbackStart
|
||||
}
|
||||
|
||||
function weekStartSettingName(index) {
|
||||
return WEEKDAY_NAMES[normalizedWeekStart(index, 1)]
|
||||
}
|
||||
|
||||
// The toggle flips between the two conventions people actually switch
|
||||
// between. A calendar configured to any other start (Saturday, say) is
|
||||
// shown as-is and lands on Monday the first time it is toggled.
|
||||
function toggledWeekStart(index) {
|
||||
return normalizedWeekStart(index, 1) === 1 ? 0 : 1
|
||||
}
|
||||
|
||||
function weekdayOrder(weekStart) {
|
||||
var start = normalizedWeekStart(weekStart, 1)
|
||||
var out = []
|
||||
for (var i = 0; i < 7; i++) out.push((start + i) % 7)
|
||||
return out
|
||||
}
|
||||
|
||||
// ISO-8601 week number: the week owning the Thursday of that date's
|
||||
// Monday-based week. Mirrors the clock widget's 'ww' format token.
|
||||
function isoWeek(year, month, day) {
|
||||
var date = new Date(Date.UTC(year, month, day))
|
||||
var weekday = date.getUTCDay() || 7
|
||||
date.setUTCDate(date.getUTCDate() + 4 - weekday)
|
||||
var yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
|
||||
return Math.ceil(((date.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7)
|
||||
}
|
||||
|
||||
function dayOfYear(year, month, day) {
|
||||
return Math.round((Date.UTC(year, month, day) - Date.UTC(year, 0, 1)) / MS_PER_DAY) + 1
|
||||
}
|
||||
|
||||
function daysInYear(year) {
|
||||
return dayOfYear(year, 11, 31)
|
||||
}
|
||||
|
||||
// Share of the year already behind you: whole days completed over days in
|
||||
// the year, so January 1 reads 0% and December 31 reads 100%.
|
||||
function yearProgress(year, month, day) {
|
||||
var total = daysInYear(year)
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(1, (dayOfYear(year, month, day) - 1) / total))
|
||||
}
|
||||
|
||||
function yearProgressPercent(year, month, day) {
|
||||
return Math.round(yearProgress(year, month, day) * 100)
|
||||
}
|
||||
|
||||
// Memento mori. The default span is a round number rather than anything from
|
||||
// an actuarial table: the point of the bar is the reminder, not the
|
||||
// arithmetic, and whoever wants a different number can say so.
|
||||
var DEFAULT_LIFE_EXPECTANCY = 90
|
||||
|
||||
// A birth year rather than an age, so the bar keeps counting on its own
|
||||
// instead of going stale the moment it is entered. 0 means "not set", which
|
||||
// is also what a blank, malformed, future, or implausibly distant year means.
|
||||
function parseBirthYear(value, currentYear) {
|
||||
var now = Math.round(Number(currentYear))
|
||||
if (!isFinite(now)) return 0
|
||||
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
|
||||
if (!/^\d{4}$/.test(text)) return 0
|
||||
var year = parseInt(text, 10)
|
||||
if (!isFinite(year) || year > now || year < now - 120) return 0
|
||||
return year
|
||||
}
|
||||
|
||||
// Whole years, the way people say their age: born in 1979 makes you 47 for
|
||||
// all of 2026, whichever side of your birthday today falls.
|
||||
function ageFromBirthYear(birthYear, currentYear) {
|
||||
var born = parseBirthYear(birthYear, currentYear)
|
||||
if (born <= 0) return 0
|
||||
return Math.round(Number(currentYear)) - born
|
||||
}
|
||||
|
||||
// 0 means "not set", which is also what a blank, negative, fractional, or
|
||||
// absurd entry means — the life bar simply stays hidden.
|
||||
function parseAge(value) {
|
||||
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
|
||||
if (!/^\d+$/.test(text)) return 0
|
||||
var years = parseInt(text, 10)
|
||||
if (!isFinite(years) || years <= 0 || years > 120) return 0
|
||||
return years
|
||||
}
|
||||
|
||||
// Unset or nonsense falls back to the default rather than to zero, so the
|
||||
// bar always has something to measure against.
|
||||
function parseLifeExpectancy(value) {
|
||||
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
|
||||
if (!/^\d+$/.test(text)) return DEFAULT_LIFE_EXPECTANCY
|
||||
var years = parseInt(text, 10)
|
||||
if (!isFinite(years) || years <= 0 || years > 150) return DEFAULT_LIFE_EXPECTANCY
|
||||
return years
|
||||
}
|
||||
|
||||
function lifeProgress(age, expectancy) {
|
||||
var years = parseAge(age)
|
||||
var span = parseLifeExpectancy(expectancy)
|
||||
if (years <= 0 || span <= 0) return 0
|
||||
return Math.max(0, Math.min(1, years / span))
|
||||
}
|
||||
|
||||
function lifeProgressPercent(age, expectancy) {
|
||||
return Math.round(lifeProgress(age, expectancy) * 100)
|
||||
}
|
||||
|
||||
// Always six rows of seven days. A fixed grid keeps the popup exactly the
|
||||
// same height in every month, so stepping through the year never makes the
|
||||
// panel jump under the pointer.
|
||||
function monthGrid(year, month, weekStart, todayKey) {
|
||||
var start = normalizedWeekStart(weekStart, 1)
|
||||
var leading = (new Date(year, month, 1).getDay() - start + 7) % 7
|
||||
var cursor = new Date(year, month, 1 - leading)
|
||||
var today = String(todayKey || "")
|
||||
var weeks = []
|
||||
|
||||
for (var w = 0; w < 6; w++) {
|
||||
var days = []
|
||||
var thursday = null
|
||||
for (var d = 0; d < 7; d++) {
|
||||
var cellYear = cursor.getFullYear()
|
||||
var cellMonth = cursor.getMonth()
|
||||
var cellDay = cursor.getDate()
|
||||
var weekday = cursor.getDay()
|
||||
var key = dateKey(cellYear, cellMonth, cellDay)
|
||||
if (weekday === 4) thursday = { year: cellYear, month: cellMonth, day: cellDay }
|
||||
days.push({
|
||||
key: key,
|
||||
year: cellYear,
|
||||
month: cellMonth,
|
||||
day: cellDay,
|
||||
weekday: weekday,
|
||||
inMonth: cellMonth === month && cellYear === year,
|
||||
weekend: weekday === 0 || weekday === 6,
|
||||
today: key === today
|
||||
})
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
// Number every row by the ISO week owning its Thursday. That is the
|
||||
// definition itself for Monday-start weeks, and the only answer that
|
||||
// stays stable for the other starts, where a row straddles two ISO
|
||||
// weeks but shares all of Monday through Thursday with one of them.
|
||||
var anchor = thursday || days[0]
|
||||
weeks.push({
|
||||
week: isoWeek(anchor.year, anchor.month, anchor.day),
|
||||
days: days
|
||||
})
|
||||
}
|
||||
return weeks
|
||||
}
|
||||
|
||||
function stepMonth(year, month, delta) {
|
||||
var target = new Date(year, Number(month) + Number(delta), 1)
|
||||
return { year: target.getFullYear(), month: target.getMonth() }
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
dateKey: dateKey,
|
||||
keyForDate: keyForDate,
|
||||
normalizedWeekStart: normalizedWeekStart,
|
||||
weekStartSettingName: weekStartSettingName,
|
||||
toggledWeekStart: toggledWeekStart,
|
||||
weekdayOrder: weekdayOrder,
|
||||
isoWeek: isoWeek,
|
||||
dayOfYear: dayOfYear,
|
||||
daysInYear: daysInYear,
|
||||
yearProgress: yearProgress,
|
||||
yearProgressPercent: yearProgressPercent,
|
||||
parseAge: parseAge,
|
||||
parseBirthYear: parseBirthYear,
|
||||
ageFromBirthYear: ageFromBirthYear,
|
||||
parseLifeExpectancy: parseLifeExpectancy,
|
||||
lifeProgress: lifeProgress,
|
||||
lifeProgressPercent: lifeProgressPercent,
|
||||
monthGrid: monthGrid,
|
||||
stepMonth: stepMonth,
|
||||
clockFormats: clockFormats,
|
||||
clockFormatRing: clockFormatRing,
|
||||
nextClockFormat: nextClockFormat,
|
||||
isoWeekLiteral: isoWeekLiteral
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
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())
|
||||
|
||||
// Memento mori, for anyone who goes looking: double-tapping the year bar
|
||||
// asks for a birth year and a life expectancy, and a second bar tracks one
|
||||
// against the other. A birth year rather than an age, so it keeps counting
|
||||
// on its own. Without one the bar stays hidden.
|
||||
readonly property int birthYear: Model.parseBirthYear(setting("birthYear", 0), today.getFullYear())
|
||||
readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear())
|
||||
readonly property int lifeExpectancy: Model.parseLifeExpectancy(setting("lifeExpectancy", 0))
|
||||
readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy)
|
||||
readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy)
|
||||
property bool editingLife: false
|
||||
|
||||
// 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() {
|
||||
refresh()
|
||||
root.controller.show()
|
||||
// Set after showing, not before: showing hands the popout coordinator
|
||||
// over, which closes whichever panel was open, and that close clears the
|
||||
// shared flag. Deferring means the panel taking over always wins, while
|
||||
// a handoff to a panel that does not manage the flag still leaves it
|
||||
// cleared rather than stuck on.
|
||||
Qt.callLater(function() {
|
||||
if (root.opened) setCenterHoverRevealSuppressed(true)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
setCenterHoverRevealSuppressed(false)
|
||||
// Dismissing the panel mid-edit would otherwise leave the inputs up,
|
||||
// waiting behind a closed popup for the next time it opens.
|
||||
if (root.editingLife) root.cancelEditingLife()
|
||||
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)
|
||||
}
|
||||
|
||||
// Applied locally first so the panel redraws 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. The host widget builds its own
|
||||
// entry when the label format is cycled, so it has to be kept in step or
|
||||
// it would write this key straight back out from a stale copy.
|
||||
function persistSettings(values) {
|
||||
var entry = { id: root.moduleName }
|
||||
for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]
|
||||
for (var key in values) entry[key] = values[key]
|
||||
|
||||
root.settings = entry
|
||||
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 setWeekStart(day) {
|
||||
var next = Model.normalizedWeekStart(day, root.weekStart)
|
||||
if (next === root.weekStart) return
|
||||
persistSettings({ weekStartDay: Model.weekStartSettingName(next) })
|
||||
}
|
||||
|
||||
function startEditingLife() {
|
||||
root.editingLife = true
|
||||
Qt.callLater(function() {
|
||||
bornField.text = root.birthYear > 0 ? String(root.birthYear) : ""
|
||||
expectancyField.text = String(root.lifeExpectancy)
|
||||
bornField.selectAll()
|
||||
bornField.forceActiveFocus()
|
||||
})
|
||||
}
|
||||
|
||||
function cancelEditingLife() {
|
||||
root.editingLife = false
|
||||
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
// Shared by both fields: Tab hops to the other one, Enter commits the pair,
|
||||
// Escape drops the lot.
|
||||
function handleLifeKey(event, other) {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
root.cancelEditingLife()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
root.commitLife()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
|
||||
other.selectAll()
|
||||
other.forceActiveFocus()
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
// Double-tapping the life bar puts it away again. The expectancy stays in
|
||||
// the config so setting a birth year again brings your own number back
|
||||
// rather than the default.
|
||||
function clearLife() {
|
||||
if (root.birthYear <= 0) return
|
||||
persistSettings({ birthYear: 0 })
|
||||
}
|
||||
|
||||
function commitLife() {
|
||||
var born = Model.parseBirthYear(bornField.text, today.getFullYear())
|
||||
var span = Model.parseLifeExpectancy(expectancyField.text)
|
||||
if (born !== root.birthYear || span !== root.lifeExpectancy)
|
||||
persistSettings({ birthYear: born, lifeExpectancy: span })
|
||||
cancelEditingLife()
|
||||
}
|
||||
|
||||
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
|
||||
blocked: root.editingLife
|
||||
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))
|
||||
|
||||
TapHandler {
|
||||
enabled: !root.editingLife
|
||||
onDoubleTapped: root.startEditingLife()
|
||||
}
|
||||
|
||||
Row {
|
||||
visible: root.editingLife
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(10)
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "BORN"
|
||||
color: Qt.darker(root.contentForeground, 1.5)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: bornField
|
||||
width: Style.space(70)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
placeholderText: "year"
|
||||
foreground: root.contentForeground
|
||||
font.family: root.contentFontFamily
|
||||
inputMethodHints: Qt.ImhDigitsOnly
|
||||
|
||||
Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) }
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: 0
|
||||
leftPadding: Style.space(6)
|
||||
text: "LIVE TO"
|
||||
color: Qt.darker(root.contentForeground, 1.5)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: expectancyField
|
||||
width: Style.space(60)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
placeholderText: "90"
|
||||
foreground: root.contentForeground
|
||||
font.family: root.contentFontFamily
|
||||
inputMethodHints: Qt.ImhDigitsOnly
|
||||
|
||||
Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: yearLabel
|
||||
visible: !root.editingLife
|
||||
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
|
||||
visible: !root.editingLife
|
||||
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
|
||||
visible: !root.editingLife
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Memento mori. Only here once someone has gone looking and
|
||||
// given an age; the same rail as the year above it, measured
|
||||
// against a nominal lifetime.
|
||||
Item {
|
||||
visible: root.birthYear > 0
|
||||
width: parent.width
|
||||
height: visible ? lifeBlock.height : 0
|
||||
|
||||
Item {
|
||||
id: lifeBlock
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: gridColumn.width
|
||||
height: Math.max(lifeLabel.implicitHeight, Style.space(10))
|
||||
|
||||
Text {
|
||||
id: lifeLabel
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "LIFE"
|
||||
color: Qt.darker(root.contentForeground, 1.5)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
id: lifePercent
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.lifeDonePercent + "%"
|
||||
color: root.contentForeground
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: lifeLabel.right
|
||||
anchors.right: lifePercent.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.lifeDone)
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: Style.selectedStateColor(root.contentForeground, Color.accent)
|
||||
|
||||
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onDoubleTapped: root.clearLife()
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: lifeMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
|
||||
PanelToolTip {
|
||||
visible: lifeMouse.containsMouse
|
||||
text: "Memento Mori"
|
||||
fontFamily: root.contentFontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -4,16 +4,16 @@
|
||||
"name": "Clock",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Day/time label; click to toggle alternate format",
|
||||
"description": "Date/time label with a calendar popup",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Clock.qml"
|
||||
"barWidget": "BarWidget.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Clock",
|
||||
"description": "Day/time label; click to toggle alternate format",
|
||||
"description": "Date/time label with a calendar popup",
|
||||
"category": "Time",
|
||||
"allowMultiple": false
|
||||
}
|
||||
@@ -12,6 +12,7 @@ BarWidget {
|
||||
if ("bar" in target) target.bar = root.bar
|
||||
if ("settings" in target) target.settings = root.settings
|
||||
if ("anchorItem" in target) target.anchorItem = button
|
||||
if ("hostWidget" in target) target.hostWidget = root
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
@@ -36,6 +37,15 @@ BarWidget {
|
||||
if (panelLoader.item && panelLoader.item.close) panelLoader.item.close()
|
||||
}
|
||||
|
||||
// Forwarded so this widget can stand in for the panel as the bar's popout
|
||||
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
|
||||
// KeyboardPanel reads popoutSwitchClosing back off its owner.
|
||||
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
|
||||
|
||||
function closeForPopoutSwitch() {
|
||||
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
|
||||
}
|
||||
|
||||
visible: panelLoader.item && panelLoader.item.label !== ""
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
@@ -15,6 +15,14 @@ Panel {
|
||||
property var anchorItem: null
|
||||
property bool openedFromHotkey: false
|
||||
|
||||
// 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
|
||||
|
||||
function open() {
|
||||
openedFromHotkey = false
|
||||
setCenterHoverRevealSuppressed(false)
|
||||
@@ -25,10 +33,17 @@ Panel {
|
||||
|
||||
function openFromHotkey() {
|
||||
openedFromHotkey = true
|
||||
setCenterHoverRevealSuppressed(true)
|
||||
root.controller.show()
|
||||
locationFile.reload()
|
||||
root.refresh()
|
||||
// Set after showing, not before: showing hands the popout coordinator
|
||||
// over, which closes whichever panel was open, and that close clears the
|
||||
// shared flag. Deferring means the panel taking over always wins, while
|
||||
// a handoff to a panel that does not manage the flag still leaves it
|
||||
// cleared rather than stuck on.
|
||||
Qt.callLater(function() {
|
||||
if (root.opened) setCenterHoverRevealSuppressed(true)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -42,6 +57,12 @@ Panel {
|
||||
else root.openFromHotkey()
|
||||
}
|
||||
|
||||
function switchPanel(direction) {
|
||||
if (root.bar && typeof root.bar.switchPanelFrom === "function")
|
||||
return root.bar.switchPanelFrom(root.barIdentity, direction)
|
||||
return false
|
||||
}
|
||||
|
||||
function setCenterHoverRevealSuppressed(value) {
|
||||
if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||
root.bar.centerHoverRevealSuppressed = value
|
||||
@@ -440,7 +461,7 @@ Panel {
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: root.anchorItem
|
||||
owner: root
|
||||
owner: root.barIdentity
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
centerOnBar: true
|
||||
|
||||
@@ -15,7 +15,55 @@ fi
|
||||
pass "bar move outline has no post-release settling state"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
|
||||
const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8')
|
||||
|
||||
// The center section declares two arrangements and shows one; the hidden one
|
||||
// must not build its modules or every center widget exists twice.
|
||||
const moduleList = barSource.slice(barSource.indexOf('component ModuleList'), barSource.indexOf('component ModuleSlot'))
|
||||
assert(
|
||||
/active: visible && entries\.length > 0/.test(moduleList),
|
||||
'bar builds only the module list it is showing'
|
||||
)
|
||||
|
||||
// A center module is mounted twice — drawn copy plus zero-size placeholder —
|
||||
// and the order they register in is not stable across a live reconfiguration,
|
||||
// so panel routing has to pick the one that is actually on screen.
|
||||
const drawn = { moduleName: 'omarchy.clock', visible: true, width: 28, height: 81 }
|
||||
const placeholder = { moduleName: 'omarchy.clock', visible: false, width: 0, height: 0 }
|
||||
assertEqual(bar.isDrawnSlot(drawn), true, 'bar recognises a drawn slot')
|
||||
assertEqual(bar.isDrawnSlot(placeholder), false, 'bar recognises a layout placeholder')
|
||||
assertEqual(bar.pickDrawnSlot([placeholder, drawn]), drawn, 'bar picks the drawn slot when the placeholder registers first')
|
||||
assertEqual(bar.pickDrawnSlot([drawn, placeholder]), drawn, 'bar picks the drawn slot when it registers first')
|
||||
assertEqual(bar.pickDrawnSlot([placeholder]), placeholder, 'bar falls back to the placeholder when nothing is drawn')
|
||||
assertEqual(bar.pickDrawnSlot([]), null, 'bar reports no slot when there are none')
|
||||
assertEqual(bar.pickDrawnSlot(null), null, 'bar tolerates a missing slot list')
|
||||
assert(
|
||||
/BarModel\.pickDrawnSlot\(candidates\)/.test(barSource),
|
||||
'bar routes panels through the drawn-slot picker'
|
||||
)
|
||||
|
||||
// The open-panel mark sits on the module's desktop-facing edge at every
|
||||
// position: under a top bar, over a bottom one, inward from left and right.
|
||||
const indicator = barSource.slice(barSource.indexOf('id: openPanelIndicator'), barSource.indexOf('id: openPanelIndicator') + 1600)
|
||||
assert(
|
||||
/x: root\.vertical\s*\n\s*\? \(root\.position === "left" \? parent\.width - width - inset : inset\)/.test(indicator),
|
||||
'bar pins the open-panel mark to the desktop-facing edge on vertical bars'
|
||||
)
|
||||
assert(
|
||||
/root\.position === "top" \? parent\.height - height - inset : inset/.test(indicator),
|
||||
'bar pins the open-panel mark to the desktop-facing edge on horizontal bars'
|
||||
)
|
||||
assert(
|
||||
/key in activeItem/.test(barSource),
|
||||
'bar asks whether a widget declares an indicator hint before reading it'
|
||||
)
|
||||
assert(
|
||||
/width: root\.vertical \? Style\.space\(2\) : slot\.panelIndicatorExtent/.test(indicator) &&
|
||||
/height: root\.vertical \? slot\.panelIndicatorExtent : Style\.space\(2\)/.test(indicator),
|
||||
'bar sizes the open-panel mark from the same content hint on both axes'
|
||||
)
|
||||
|
||||
assertEqual(bar.normalizePosition('left'), 'left', 'bar accepts valid positions')
|
||||
assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid positions')
|
||||
|
||||
Executable
+218
@@ -0,0 +1,218 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const calendar = requireFromRoot('shell/plugins/panels/clock/Model.js')
|
||||
const panelSource = fs.readFileSync(root + '/shell/plugins/panels/clock/Panel.qml', 'utf8')
|
||||
const widgetSource = fs.readFileSync(root + '/shell/plugins/panels/clock/BarWidget.qml', 'utf8')
|
||||
|
||||
// ---- week start resolution
|
||||
assertEqual(calendar.normalizedWeekStart('monday', 0), 1, 'calendar reads a named week start')
|
||||
assertEqual(calendar.normalizedWeekStart('SUN', 1), 0, 'calendar reads an abbreviated week start')
|
||||
assertEqual(calendar.normalizedWeekStart(6, 1), 6, 'calendar reads a numeric week start')
|
||||
assertEqual(calendar.normalizedWeekStart(null, 0), 0, 'calendar falls back to the locale first day')
|
||||
assertEqual(calendar.normalizedWeekStart('garbage', 0), 0, 'calendar falls back when the setting is unparseable')
|
||||
assertEqual(calendar.normalizedWeekStart(undefined, undefined), 1, 'calendar defaults to Monday without a locale')
|
||||
|
||||
assertEqual(calendar.weekStartSettingName(0), 'sunday', 'calendar persists Sunday by name')
|
||||
assertEqual(calendar.weekStartSettingName(1), 'monday', 'calendar persists Monday by name')
|
||||
|
||||
assertEqual(calendar.toggledWeekStart(1), 0, 'calendar toggles Monday to Sunday')
|
||||
assertEqual(calendar.toggledWeekStart(0), 1, 'calendar toggles Sunday to Monday')
|
||||
assertEqual(calendar.toggledWeekStart(6), 1, 'calendar toggles an exotic week start to Monday')
|
||||
|
||||
assertDeepEqual(calendar.weekdayOrder(1), [1, 2, 3, 4, 5, 6, 0], 'calendar orders weekdays from Monday')
|
||||
assertDeepEqual(calendar.weekdayOrder(0), [0, 1, 2, 3, 4, 5, 6], 'calendar orders weekdays from Sunday')
|
||||
|
||||
// ---- ISO week numbers, matching the clock widget's 'ww' token
|
||||
assertEqual(calendar.isoWeek(2026, 0, 1), 1, 'calendar numbers a Thursday January 1 as week 1')
|
||||
assertEqual(calendar.isoWeek(2021, 0, 1), 53, 'calendar numbers early January into the previous ISO year')
|
||||
assertEqual(calendar.isoWeek(2026, 11, 31), 53, 'calendar numbers the last week of a long year')
|
||||
assertEqual(calendar.isoWeek(2026, 6, 26), 30, 'calendar numbers a midsummer Sunday')
|
||||
|
||||
// ---- day-of-year arithmetic behind the hero stats
|
||||
assertEqual(calendar.dayOfYear(2026, 6, 26), 207, 'calendar counts the day of the year')
|
||||
assertEqual(calendar.yearProgressPercent(2026, 0, 1), 0, 'calendar starts the year at zero percent done')
|
||||
assertEqual(calendar.yearProgressPercent(2026, 6, 26), 56, 'calendar reports the share of the year behind you')
|
||||
assertEqual(calendar.yearProgressPercent(2026, 11, 31), 100, 'calendar finishes the year at a hundred percent')
|
||||
assertEqual(calendar.yearProgressPercent(2024, 11, 31), 100, 'calendar finishes a leap year too')
|
||||
|
||||
// ---- memento mori
|
||||
// A birth year rather than an age, so the bar keeps counting on its own.
|
||||
assertEqual(calendar.parseBirthYear('1979', 2026), 1979, 'calendar reads a birth year')
|
||||
assertEqual(calendar.parseBirthYear(1979, 2026), 1979, 'calendar reads a numeric birth year')
|
||||
assertEqual(calendar.parseBirthYear(' 1979 ', 2026), 1979, 'calendar trims a birth year')
|
||||
assertEqual(calendar.parseBirthYear('2026', 2026), 2026, 'calendar accepts the current year')
|
||||
assertEqual(calendar.parseBirthYear('2027', 2026), 0, 'calendar rejects a birth year in the future')
|
||||
assertEqual(calendar.parseBirthYear('1905', 2026), 0, 'calendar rejects an implausibly distant birth year')
|
||||
assertEqual(calendar.parseBirthYear('79', 2026), 0, 'calendar rejects a two-digit year')
|
||||
assertEqual(calendar.parseBirthYear('', 2026), 0, 'calendar treats a blank birth year as unset')
|
||||
assertEqual(calendar.parseBirthYear('abc', 2026), 0, 'calendar treats a non-numeric birth year as unset')
|
||||
|
||||
assertEqual(calendar.ageFromBirthYear('1979', 2026), 47, 'calendar derives an age from a birth year')
|
||||
assertEqual(calendar.ageFromBirthYear('1979', 2027), 48, 'calendar keeps counting as the years pass')
|
||||
assertEqual(calendar.ageFromBirthYear('2026', 2026), 0, 'calendar makes someone born this year zero')
|
||||
assertEqual(calendar.ageFromBirthYear('', 2026), 0, 'calendar derives no age without a birth year')
|
||||
|
||||
assertEqual(calendar.parseAge('47'), 47, 'calendar reads an age')
|
||||
assertEqual(calendar.parseAge(47), 47, 'calendar reads a numeric age')
|
||||
assertEqual(calendar.parseAge(' 47 '), 47, 'calendar trims an age')
|
||||
assertEqual(calendar.parseAge(''), 0, 'calendar treats a blank age as unset')
|
||||
assertEqual(calendar.parseAge('0'), 0, 'calendar treats zero as unset')
|
||||
assertEqual(calendar.parseAge('-3'), 0, 'calendar treats a negative age as unset')
|
||||
assertEqual(calendar.parseAge('121'), 0, 'calendar treats an implausible age as unset')
|
||||
assertEqual(calendar.parseAge('abc'), 0, 'calendar treats a non-numeric age as unset')
|
||||
assertEqual(calendar.parseAge('4.5'), 0, 'calendar treats a fractional age as unset')
|
||||
assertEqual(calendar.parseLifeExpectancy('65'), 65, 'calendar reads a life expectancy')
|
||||
assertEqual(calendar.parseLifeExpectancy(''), 90, 'calendar defaults the life expectancy to ninety')
|
||||
assertEqual(calendar.parseLifeExpectancy(0), 90, 'calendar defaults an unset life expectancy')
|
||||
assertEqual(calendar.parseLifeExpectancy('abc'), 90, 'calendar defaults a non-numeric life expectancy')
|
||||
assertEqual(calendar.parseLifeExpectancy('200'), 90, 'calendar defaults an implausible life expectancy')
|
||||
|
||||
assertEqual(calendar.lifeProgressPercent(45, 90), 50, 'calendar measures a life against the expectancy given')
|
||||
assertEqual(calendar.lifeProgressPercent(45, 65), 69, 'calendar honours a shorter expectancy')
|
||||
assertEqual(calendar.lifeProgressPercent(45, ''), 50, 'calendar falls back to ninety when no expectancy is set')
|
||||
assertEqual(calendar.lifeProgressPercent(90, 90), 100, 'calendar fills the life bar at the expectancy')
|
||||
assertEqual(calendar.lifeProgressPercent(80, 65), 100, 'calendar never overfills the life bar')
|
||||
assertEqual(calendar.lifeProgressPercent(0, 90), 0, 'calendar leaves the life bar empty when no age is set')
|
||||
assertEqual(calendar.daysInYear(2024), 366, 'calendar knows leap years are longer')
|
||||
assertEqual(calendar.daysInYear(2026), 365, 'calendar knows common years')
|
||||
|
||||
// ---- month grid
|
||||
const july = calendar.monthGrid(2026, 6, 1, '2026-07-26')
|
||||
assertEqual(july.length, 6, 'calendar always draws six week rows')
|
||||
assert(july.every(week => week.days.length === 7), 'calendar always draws seven day columns')
|
||||
assertDeepEqual(july.map(week => week.week), [27, 28, 29, 30, 31, 32], 'calendar numbers every row by ISO week')
|
||||
assertDeepEqual(july[0].days.map(day => day.day), [29, 30, 1, 2, 3, 4, 5], 'calendar pads the first row with the previous month')
|
||||
assertDeepEqual(july[0].days.map(day => day.inMonth), [false, false, true, true, true, true, true], 'calendar marks padding days as outside the month')
|
||||
assertDeepEqual(july[0].days.map(day => day.weekend), [false, false, false, false, false, true, true], 'calendar marks Saturday and Sunday as the weekend')
|
||||
|
||||
assertDeepEqual(
|
||||
july.flatMap(week => week.days).filter(day => day.today).map(day => day.key),
|
||||
['2026-07-26'],
|
||||
'calendar marks exactly one cell as today'
|
||||
)
|
||||
assert(july.flatMap(week => week.days).every(day => day.cursor === undefined), 'calendar grid carries no selection state')
|
||||
|
||||
// Both conventions land on the same ISO week numbers, because every row is
|
||||
// numbered by the ISO week owning its Thursday.
|
||||
const julySunday = calendar.monthGrid(2026, 6, 0, '')
|
||||
assertDeepEqual(julySunday[0].days.map(day => day.day), [28, 29, 30, 1, 2, 3, 4], 'calendar shifts the grid for a Sunday week start')
|
||||
assertDeepEqual(julySunday.map(week => week.week), [27, 28, 29, 30, 31, 32], 'calendar keeps ISO week numbers across week starts')
|
||||
|
||||
const januarySunday = calendar.monthGrid(2021, 0, 0, '')
|
||||
assertEqual(januarySunday[0].week, 53, 'calendar carries the previous ISO year into a straddling first row')
|
||||
|
||||
// ---- stepping
|
||||
assertDeepEqual(calendar.stepMonth(2026, 0, 1), { year: 2026, month: 1 }, 'calendar steps to the next month')
|
||||
assertDeepEqual(calendar.stepMonth(2026, 0, -1), { year: 2025, month: 11 }, 'calendar steps back across the new year')
|
||||
assertDeepEqual(calendar.stepMonth(2026, 11, 1), { year: 2027, month: 0 }, 'calendar steps forward across the new year')
|
||||
assertDeepEqual(calendar.stepMonth(2026, 6, 12), { year: 2027, month: 6 }, 'calendar steps a whole year at a time')
|
||||
|
||||
assertEqual(calendar.dateKey(2026, 0, 5), '2026-01-05', 'calendar zero-pads date keys')
|
||||
assertEqual(calendar.keyForDate(new Date(2026, 6, 26)), '2026-07-26', 'calendar keys a Date the same way')
|
||||
|
||||
// ---- bar label format ring
|
||||
// The order must not depend on which entry is current: cycling writes the
|
||||
// result back to shell.json, so a self-reordering ring would ping-pong.
|
||||
const ring = calendar.clockFormatRing('dddd HH:mm', "d MMMM 'W'ww yyyy", calendar.clockFormats(false))
|
||||
assertDeepEqual(ring, calendar.clockFormats(false), 'clock rings the stock presets in their own order')
|
||||
assertEqual(new Set(ring).size, ring.length, 'clock never offers the same format twice')
|
||||
|
||||
assertDeepEqual(
|
||||
calendar.clockFormatRing("d MMMM 'W'ww yyyy", 'dddd HH:mm', calendar.clockFormats(false)),
|
||||
ring,
|
||||
'clock keeps the ring order steady as the current format moves through it'
|
||||
)
|
||||
assertDeepEqual(
|
||||
calendar.clockFormatRing('HH:mm:ss', '', ['HH:mm', 'dddd HH:mm']),
|
||||
['HH:mm', 'dddd HH:mm', 'HH:mm:ss'],
|
||||
'clock appends a hand-written format to the end of the ring'
|
||||
)
|
||||
assertDeepEqual(calendar.clockFormatRing('', '', []), ['HH:mm'], 'clock keeps a usable format when nothing is configured')
|
||||
|
||||
assertEqual(calendar.nextClockFormat(ring, ring[0]), ring[1], 'clock steps to the next format')
|
||||
assertEqual(calendar.nextClockFormat(ring, ring[ring.length - 1]), ring[0], 'clock wraps the format ring')
|
||||
assertEqual(calendar.nextClockFormat(ring, 'HH:mm:ss'), ring[0], 'clock starts at the top from a format outside the ring')
|
||||
assertEqual(calendar.clockFormats(true)[0], 'HH\n\u2014\nmm', 'clock keeps stacked formats for vertical bars')
|
||||
assertEqual(calendar.isoWeekLiteral(2026, 0, 5), '02', 'clock zero-pads the ISO week token')
|
||||
|
||||
// ---- widget wiring
|
||||
assert(/moduleName: "omarchy\.clock"/.test(panelSource), 'calendar panel declares its module name')
|
||||
assert(/ipcTarget: "omarchy\.clock"/.test(panelSource), 'calendar panel registers its IPC target')
|
||||
assert(/manageIpc: false/.test(panelSource), 'calendar panel leaves the IPC target to the bar widget')
|
||||
assert(/anchorItem: root\.anchorItem/.test(panelSource), 'calendar panel anchors to the host widget button')
|
||||
assert(/function toggleWeekStart\(\)/.test(panelSource), 'calendar panel exposes a week start toggle')
|
||||
assert(/setting\("weekStartDay", null\)/.test(panelSource) && /persistSettings\(\{ weekStartDay:/.test(panelSource), 'calendar reads and writes the week start as weekStartDay')
|
||||
assert(/updateEntryInline/.test(panelSource), 'calendar panel persists the week start to shell.json')
|
||||
assert(/function moveMonth\(delta\)/.test(panelSource), 'calendar panel steps between months')
|
||||
assert(!/property bool onToday/.test(panelSource) && !/root\.onToday/.test(panelSource), 'calendar panel avoids the on-prefixed property name QML reads as a signal handler')
|
||||
assert(/readonly property bool viewingCurrentMonth:/.test(panelSource), 'calendar panel tracks whether the current month is on screen')
|
||||
assert(!/MouseArea/.test(panelSource.slice(panelSource.indexOf('model: modelData.days'), panelSource.indexOf('// Hairline'))), 'calendar day cells are not selectable')
|
||||
assert(/yearDone: Model\.yearProgress\(today\./.test(panelSource), 'calendar year bar stays pinned to today while months are stepped')
|
||||
assert(/Qt\.callLater\(function\(\) \{\s*\n\s*if \(root\.opened\) setCenterHoverRevealSuppressed\(true\)/.test(panelSource), 'calendar claims the shared hover-reveal flag after the popout handoff, so the panel taking over wins')
|
||||
assert(/function close\(\) \{\s*\n\s*setCenterHoverRevealSuppressed\(false\)/.test(panelSource), 'calendar always releases the shared hover-reveal flag on close')
|
||||
assert(/width: Math\.max\(calendarScroll\.width, gridColumn\.width\)/.test(panelSource), 'calendar scrolls rather than clipping the grid on a narrow popup')
|
||||
assert(/enabled: !root\.viewingCurrentMonth/.test(panelSource) && /onClicked: root\.goToToday\(\)/.test(panelSource), 'calendar hero returns to today once the view has stepped away')
|
||||
assert(!/clampMonth/.test(panelSource), 'calendar steps freely into future months')
|
||||
assert(/Qt\.formatDate\(root\.today, "MMMM d"\)/.test(panelSource), 'calendar hero spells out today')
|
||||
assert(/id: yearLabel/.test(panelSource) && /root\.yearDone/.test(panelSource), 'calendar panel shows the year progress bar')
|
||||
|
||||
// The memento mori bar is opt-in: double-tapping the year bar asks for an age,
|
||||
// and nothing shows until one has been given.
|
||||
assert(/onDoubleTapped: root\.startEditingLife\(\)/.test(panelSource), 'calendar asks for an age when the year bar is double-tapped')
|
||||
assert(/persistSettings\(\{ birthYear: born, lifeExpectancy: span \}\)/.test(panelSource), 'calendar saves birth year and expectancy together, so neither lands on a stale copy')
|
||||
assert(/readonly property int birthYear: Model\.parseBirthYear\(setting\("birthYear", 0\)/.test(panelSource), 'calendar reads the saved birth year back')
|
||||
assert(/readonly property int age: Model\.ageFromBirthYear\(birthYear/.test(panelSource), 'calendar derives the age from the stored birth year')
|
||||
assert(/readonly property int lifeExpectancy: Model\.parseLifeExpectancy\(setting\("lifeExpectancy", 0\)\)/.test(panelSource), 'calendar reads the saved expectancy back')
|
||||
assert(/id: expectancyField/.test(panelSource) && /id: bornField/.test(panelSource), 'calendar offers both inputs')
|
||||
assert(/visible: root\.editingLife\s*\n\s*anchors\.horizontalCenter: parent\.horizontalCenter/.test(panelSource), 'calendar centers the inputs over the bar they replace')
|
||||
assert(/visible: root\.birthYear > 0/.test(panelSource), 'calendar hides the life bar until a birth year is known')
|
||||
assert(/text: "LIFE"/.test(panelSource) && /root\.lifeDone/.test(panelSource), 'calendar shows the life bar')
|
||||
assert(/text: "Memento Mori"/.test(panelSource), 'calendar names the life bar on hover')
|
||||
assert(/onDoubleTapped: root\.clearLife\(\)/.test(panelSource), 'calendar puts the life bar away when it is double-tapped')
|
||||
assert(/persistSettings\(\{ birthYear: 0 \}\)/.test(panelSource), 'calendar clears the birth year to hide the life bar')
|
||||
assertEqual(calendar.parseBirthYear(0, 2026), 0, 'a cleared birth year reads back as unset')
|
||||
assert(/blocked: root\.editingLife/.test(panelSource), 'calendar lets the inputs have the keyboard while they are up')
|
||||
assert(/if \(root\.editingLife\) root\.cancelEditingLife\(\)/.test(panelSource), 'calendar drops a half-finished edit when the panel closes')
|
||||
|
||||
assert(/source: Qt\.resolvedUrl\("Panel\.qml"\)/.test(widgetSource), 'clock widget hosts the calendar panel')
|
||||
assert(/readonly property bool opened:/.test(widgetSource), 'clock widget exposes the panel open state to shell routing')
|
||||
assert(/Qt\.RightButton\) root\.cycleFormat\(\)/.test(widgetSource), 'clock right click cycles the label format')
|
||||
assert(/readonly property string activeFormat: configuredFormat/.test(widgetSource), 'clock shows the format it has stored')
|
||||
assert(/entry\[vertical \? "verticalFormat" : "format"\] = next/.test(widgetSource) && /updateEntryInline/.test(widgetSource), 'clock writes a cycled format back to shell.json')
|
||||
assert(!/formatIndex/.test(widgetSource), 'clock keeps no session-only format position')
|
||||
assert(/else root\.togglePanel\(\)/.test(widgetSource), 'clock left click reveals the calendar')
|
||||
assert(/omarchy-menu-timezone/.test(widgetSource), 'clock keeps the timezone picker on middle click')
|
||||
|
||||
// The bar identifies a panel by the widget in its slot, so the nested panel
|
||||
// has to present the host widget rather than itself.
|
||||
assert(/owner: root\.barIdentity/.test(panelSource), 'calendar panel gives the bar its host widget as popout identity')
|
||||
assert(/switchPanelFrom\(root\.barIdentity, direction\)/.test(panelSource), 'calendar panel switches panels as its host widget')
|
||||
assert(/hostWidget" in target\) target\.hostWidget = root/.test(widgetSource), 'clock widget injects itself as the panel host')
|
||||
assert(/readonly property bool popoutSwitchClosing:/.test(widgetSource) && /function closeForPopoutSwitch\(\)/.test(widgetSource), 'clock widget forwards the popout-switch handshake')
|
||||
assert(/openPanelIndicatorWidth: button\.labelWidth/.test(widgetSource), 'clock sizes the open-panel dot to its label')
|
||||
assert(/openPanelIndicatorHeight: Math\.max/.test(widgetSource), 'clock sizes the vertical open-panel dot to one stacked line')
|
||||
const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8')
|
||||
assert(/openPanelIndicatorWidth/.test(barSource) && /openPanelIndicatorHeight/.test(barSource), 'bar honours a widget-supplied open-panel dot size on both axes')
|
||||
assert(/height: root\.vertical \? slot\.panelIndicatorExtent : Style\.space\(2\)/.test(barSource), 'bar sizes the vertical dot from the same hint as the horizontal one')
|
||||
assert(/readonly property real labelWidth:/.test(fs.readFileSync(root + '/shell/Ui/WidgetButton.qml', 'utf8')), 'widget buttons expose their painted label width')
|
||||
|
||||
// A horizontal wheel reports angleDelta.y === 0; without the guard every one
|
||||
// of them would read as a forward step.
|
||||
assert(/if \(event\.angleDelta\.y === 0\) return/.test(panelSource), 'calendar ignores wheel events with no vertical delta')
|
||||
JS
|
||||
|
||||
shell_json=$(cd "$ROOT" && jq -r '[.bar.layout.center[].id] | join(",")' config/omarchy/shell.json)
|
||||
[[ $shell_json == *"omarchy.clock"* ]] || fail "default bar layout includes the clock widget" "center: $shell_json"
|
||||
[[ $shell_json != *"omarchy.calendar"* ]] || fail "clock hosts the calendar instead of a second bar pill" "center: $shell_json"
|
||||
pass "default bar layout includes the clock widget"
|
||||
|
||||
grep -q 'o.bind("SUPER + CTRL + ALT + D", "Calendar", "omarchy-shell shell toggle omarchy.clock")' \
|
||||
"$ROOT/default/hypr/bindings/utilities.lua" ||
|
||||
fail "SUPER+CTRL+ALT+D toggles the calendar panel"
|
||||
pass "SUPER+CTRL+ALT+D toggles the calendar panel"
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command jq
|
||||
require_command python3
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
mkdir -p "$TMPDIR/home/.config/omarchy"
|
||||
|
||||
clone() {
|
||||
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" \
|
||||
omarchy-plugin-clone "$1" "$2" --name "$3"
|
||||
}
|
||||
|
||||
# A bar widget that pulls in a sibling JS module clones into a directory that
|
||||
# does not contain it, so the import has to be rewritten back to the bundled
|
||||
# file or the cloned widget fails to load.
|
||||
clone omarchy.clock local.clock-clone "Cloned Clock" >/dev/null
|
||||
widget="$TMPDIR/home/.config/omarchy/plugins/local.clock-clone/Widget.qml"
|
||||
|
||||
[[ -f $widget ]] || fail "clone produces a widget file"
|
||||
pass "clone produces a widget file"
|
||||
|
||||
grep -q 'moduleName: "local.clock-clone"' "$widget" ||
|
||||
fail "clone rewrites the module name"
|
||||
pass "clone rewrites the module name"
|
||||
|
||||
while read -r ref; do
|
||||
[[ $ref == file://* ]] || fail "clone leaves a relative reference behind" "$ref"
|
||||
done < <(grep -oE '(import|Qt\.resolvedUrl\() *"[^"]+"' "$widget" |
|
||||
grep -oE '"[^"]+"' | tr -d '"' | grep -E '\.(js|qml)$')
|
||||
pass "clone resolves every relative QML and JS reference to the bundled file"
|
||||
|
||||
for ref in $(grep -oE 'file://[^"]+\.(js|qml)' "$widget"); do
|
||||
path=${ref#file://}
|
||||
[[ -f $path ]] || fail "cloned reference points at a real file" "$ref"
|
||||
done
|
||||
pass "cloned references point at files that exist"
|
||||
|
||||
# The bundled widget genuinely has such an import, so the check above is not
|
||||
# passing by accident.
|
||||
grep -qE '^import "[^"/][^"]*\.js"' "$ROOT/shell/plugins/panels/clock/BarWidget.qml" ||
|
||||
fail "the clock widget still imports a sibling JS module"
|
||||
pass "the clock widget still imports a sibling JS module"
|
||||
@@ -8,6 +8,7 @@ run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const weather = requireFromRoot('shell/plugins/panels/weather/Model.js')
|
||||
const panelSource = fs.readFileSync(root + '/shell/plugins/panels/weather/Panel.qml', 'utf8')
|
||||
const widgetSource = fs.readFileSync(root + '/shell/plugins/panels/weather/BarWidget.qml', 'utf8')
|
||||
|
||||
assertDeepEqual(
|
||||
weather.parseWeatherStatus('{"text":"☀","class":"sunny"}'),
|
||||
@@ -115,6 +116,30 @@ assertEqual(weather.currentIcon({ openMeteoWeatherCode: 0, isDay: 0 }, ''), weat
|
||||
assert(weather.iconForOpenMeteoCode(45, true) !== weather.iconForOpenMeteoCode(45, false), 'weather distinguishes nighttime fog from daytime fog')
|
||||
assertEqual(weather.provisionalCurrentIcon({ weatherCode: 113 }, ''), weather.iconForCode(113, false), 'weather uses wttr to fill an empty initial icon')
|
||||
assertEqual(weather.provisionalCurrentIcon({ weatherCode: 113 }, 'night'), 'night', 'weather refresh preserves a resolved day-night icon')
|
||||
// The bar identifies a panel by the widget in its slot, so the nested panel
|
||||
// has to present the host widget rather than itself — otherwise the
|
||||
// open-panel dot never lights and Tab cannot leave the panel.
|
||||
assert(
|
||||
panelSource.includes('owner: root.barIdentity'),
|
||||
'weather panel gives the bar its host widget as popout identity'
|
||||
)
|
||||
assert(
|
||||
panelSource.includes('switchPanelFrom(root.barIdentity, direction)'),
|
||||
'weather panel switches panels as its host widget'
|
||||
)
|
||||
assert(
|
||||
widgetSource.includes('target.hostWidget = root'),
|
||||
'weather widget injects itself as the panel host'
|
||||
)
|
||||
assert(
|
||||
widgetSource.includes('readonly property bool popoutSwitchClosing:') && widgetSource.includes('function closeForPopoutSwitch()'),
|
||||
'weather widget forwards the popout-switch handshake'
|
||||
)
|
||||
assert(
|
||||
/Qt\.callLater\(function\(\) \{\s*\n\s*if \(root\.opened\) setCenterHoverRevealSuppressed\(true\)/.test(panelSource),
|
||||
'weather claims the shared hover-reveal flag after the popout handoff, so the panel taking over wins'
|
||||
)
|
||||
|
||||
assert(
|
||||
panelSource.includes('text: root.label || "—"'),
|
||||
'weather hero and bar use the same resolved icon'
|
||||
|
||||
Reference in New Issue
Block a user