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)
|
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
|
# Indicators dynamically loads sibling indicator components via string
|
||||||
# concatenation, so the simple Qt.resolvedUrl("literal") rewrite above cannot
|
# concatenation, so the simple Qt.resolvedUrl("literal") rewrite above cannot
|
||||||
# see it. Point the clone back at Omarchy's bundled indicator directory.
|
# 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 + A", "Audio", "omarchy-shell shell toggle omarchy.audio")
|
||||||
o.bind("SUPER + CTRL + B", "Bluetooth", "omarchy-shell shell toggle omarchy.bluetooth")
|
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 + 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 + W", "Network", "omarchy-shell shell toggle omarchy.network")
|
||||||
o.bind("SUPER + CTRL + P", "Power", "omarchy-shell shell toggle omarchy.power")
|
o.bind("SUPER + CTRL + P", "Power", "omarchy-shell shell toggle omarchy.power")
|
||||||
o.bind("SUPER + CTRL + T", "Activity", { tui = "btop" })
|
o.bind("SUPER + CTRL + T", "Activity", { tui = "btop" })
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ Item {
|
|||||||
readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin)
|
readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin)
|
||||||
readonly property real scaledVerticalPadding: Style.spaceReal(verticalPadding)
|
readonly property real scaledVerticalPadding: Style.spaceReal(verticalPadding)
|
||||||
readonly property bool tooltipHovered: visible && interactive && !concealed && mouseArea.containsMouse
|
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
|
visible: hasVisualContent || keepSpace
|
||||||
opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1)
|
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` |
|
| Notifications | `omarchy.notifications` | `service` | `notifications/Service.qml` |
|
||||||
| Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` |
|
| Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` |
|
||||||
| Bluetooth | `omarchy.bluetooth` | `bar-widget` | `panels/bluetooth/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` |
|
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
|
||||||
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
||||||
| Power | `omarchy.power` | `bar-widget` | `panels/power/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` |
|
| Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` |
|
||||||
|
|
||||||
First-party bar-only widgets also carry manifests next to their QML files,
|
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`.
|
own plugin directories, each with its own `manifest.json`.
|
||||||
|
|
||||||
## Bar
|
## Bar
|
||||||
|
|||||||
@@ -406,15 +406,19 @@ Item {
|
|||||||
function findPanelWidget(pluginId) {
|
function findPanelWidget(pluginId) {
|
||||||
var id = String(pluginId || "")
|
var id = String(pluginId || "")
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
|
var candidates = []
|
||||||
for (var i = 0; i < moduleSlots.length; i++) {
|
for (var i = 0; i < moduleSlots.length; i++) {
|
||||||
var slot = moduleSlots[i]
|
var slot = moduleSlots[i]
|
||||||
if (!slot || !slot.activeItem) continue
|
if (!slot || !slot.activeItem) continue
|
||||||
if (slot.moduleName !== id) continue
|
if (slot.moduleName !== id) continue
|
||||||
var item = slot.activeItem
|
var item = slot.activeItem
|
||||||
if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue
|
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) {
|
function summonBarWidget(pluginId) {
|
||||||
@@ -1317,6 +1321,12 @@ Item {
|
|||||||
property string region: ""
|
property string region: ""
|
||||||
|
|
||||||
visible: entries.length > 0
|
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
|
sourceComponent: root.vertical ? verticalModuleList : horizontalModuleList
|
||||||
width: item ? item.implicitWidth : 0
|
width: item ? item.implicitWidth : 0
|
||||||
height: item ? item.implicitHeight : 0
|
height: item ? item.implicitHeight : 0
|
||||||
@@ -1386,6 +1396,16 @@ Item {
|
|||||||
readonly property bool hovered: moduleHover.hovered
|
readonly property bool hovered: moduleHover.hovered
|
||||||
readonly property bool dragSource: root.barDragSource === slot
|
readonly property bool dragSource: root.barDragSource === slot
|
||||||
readonly property bool panelOpen: root.activePopout === slot.activeItem
|
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
|
implicitWidth: activeItem && activeItem.visible ? (root.vertical ? root.barSize : activeItem.implicitWidth) : 0
|
||||||
implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0
|
implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0
|
||||||
width: implicitWidth
|
width: implicitWidth
|
||||||
@@ -1455,8 +1475,12 @@ Item {
|
|||||||
opacity: slot.panelOpen && !slot.dragSource ? 0.9 : 0
|
opacity: slot.panelOpen && !slot.dragSource ? 0.9 : 0
|
||||||
color: Color.accent
|
color: Color.accent
|
||||||
radius: Math.min(width, height) / 2
|
radius: Math.min(width, height) / 2
|
||||||
width: root.vertical ? Style.space(2) : Math.max(Style.space(10), Math.round(parent.width * 0.55))
|
width: root.vertical ? Style.space(2) : slot.panelIndicatorExtent
|
||||||
height: root.vertical ? Math.max(Style.space(10), Math.round(parent.height * 0.55)) : Style.space(2)
|
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
|
x: root.vertical
|
||||||
? (root.position === "left" ? parent.width - width - inset : inset)
|
? (root.position === "left" ? parent.width - width - inset : inset)
|
||||||
: Math.round((parent.width - width) / 2)
|
: Math.round((parent.width - width) / 2)
|
||||||
|
|||||||
@@ -96,8 +96,32 @@ function customModulePath(entry, home, configDir) {
|
|||||||
return source
|
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") {
|
if (typeof module !== "undefined") {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
isDrawnSlot: isDrawnSlot,
|
||||||
|
pickDrawnSlot: pickDrawnSlot,
|
||||||
normalizePosition: normalizePosition,
|
normalizePosition: normalizePosition,
|
||||||
entrySettings: entrySettings,
|
entrySettings: entrySettings,
|
||||||
entryId: entryId,
|
entryId: entryId,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Example `shell.json` (bar subtree only shown):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `omarchy.menu` | Omarchy menu launcher | left = menu · right = terminal |
|
| `omarchy.menu` | Omarchy menu launcher | left = menu · right = terminal |
|
||||||
| `omarchy.workspaces` | Hyprland workspace switcher | left = focus workspace |
|
| `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.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.indicators` | Manual state indicators | left = indicator action |
|
||||||
| `omarchy.system-update` | Available update indicator | left = update |
|
| `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
|
- `bar.requestPopout(owner)` / `bar.releasePopout(owner)` — one-popup-at-a-time coordinator
|
||||||
|
|
||||||
First-party bar widgets are manifest-backed just like third-party widgets.
|
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/`,
|
richer popup plugins live in feature directories such as `../panels/audio/`,
|
||||||
`../panels/network/`, and `../model-usage/`; and feature plugins such as
|
`../panels/network/`, and `../model-usage/`; and feature plugins such as
|
||||||
`omarchy.menu` and `omarchy.media` declare their bar-widget entry points in their own
|
`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",
|
"name": "Clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "Omarchy",
|
"author": "Omarchy",
|
||||||
"description": "Day/time label; click to toggle alternate format",
|
"description": "Date/time label with a calendar popup",
|
||||||
"kinds": [
|
"kinds": [
|
||||||
"bar-widget"
|
"bar-widget"
|
||||||
],
|
],
|
||||||
"entryPoints": {
|
"entryPoints": {
|
||||||
"barWidget": "Clock.qml"
|
"barWidget": "BarWidget.qml"
|
||||||
},
|
},
|
||||||
"barWidget": {
|
"barWidget": {
|
||||||
"displayName": "Clock",
|
"displayName": "Clock",
|
||||||
"description": "Day/time label; click to toggle alternate format",
|
"description": "Date/time label with a calendar popup",
|
||||||
"category": "Time",
|
"category": "Time",
|
||||||
"allowMultiple": false
|
"allowMultiple": false
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ BarWidget {
|
|||||||
if ("bar" in target) target.bar = root.bar
|
if ("bar" in target) target.bar = root.bar
|
||||||
if ("settings" in target) target.settings = root.settings
|
if ("settings" in target) target.settings = root.settings
|
||||||
if ("anchorItem" in target) target.anchorItem = button
|
if ("anchorItem" in target) target.anchorItem = button
|
||||||
|
if ("hostWidget" in target) target.hostWidget = root
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
@@ -36,6 +37,15 @@ BarWidget {
|
|||||||
if (panelLoader.item && panelLoader.item.close) panelLoader.item.close()
|
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 !== ""
|
visible: panelLoader.item && panelLoader.item.label !== ""
|
||||||
implicitWidth: button.implicitWidth
|
implicitWidth: button.implicitWidth
|
||||||
implicitHeight: button.implicitHeight
|
implicitHeight: button.implicitHeight
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ Panel {
|
|||||||
property var anchorItem: null
|
property var anchorItem: null
|
||||||
property bool openedFromHotkey: false
|
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() {
|
function open() {
|
||||||
openedFromHotkey = false
|
openedFromHotkey = false
|
||||||
setCenterHoverRevealSuppressed(false)
|
setCenterHoverRevealSuppressed(false)
|
||||||
@@ -25,10 +33,17 @@ Panel {
|
|||||||
|
|
||||||
function openFromHotkey() {
|
function openFromHotkey() {
|
||||||
openedFromHotkey = true
|
openedFromHotkey = true
|
||||||
setCenterHoverRevealSuppressed(true)
|
|
||||||
root.controller.show()
|
root.controller.show()
|
||||||
locationFile.reload()
|
locationFile.reload()
|
||||||
root.refresh()
|
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() {
|
function close() {
|
||||||
@@ -42,6 +57,12 @@ Panel {
|
|||||||
else root.openFromHotkey()
|
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) {
|
function setCenterHoverRevealSuppressed(value) {
|
||||||
if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||||
root.bar.centerHoverRevealSuppressed = value
|
root.bar.centerHoverRevealSuppressed = value
|
||||||
@@ -440,7 +461,7 @@ Panel {
|
|||||||
KeyboardPanel {
|
KeyboardPanel {
|
||||||
id: panel
|
id: panel
|
||||||
anchorItem: root.anchorItem
|
anchorItem: root.anchorItem
|
||||||
owner: root
|
owner: root.barIdentity
|
||||||
bar: root.bar
|
bar: root.bar
|
||||||
open: root.opened
|
open: root.opened
|
||||||
centerOnBar: true
|
centerOnBar: true
|
||||||
|
|||||||
@@ -15,7 +15,55 @@ fi
|
|||||||
pass "bar move outline has no post-release settling state"
|
pass "bar move outline has no post-release settling state"
|
||||||
|
|
||||||
run_node_test <<'JS'
|
run_node_test <<'JS'
|
||||||
|
const fs = require('fs')
|
||||||
const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
|
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('left'), 'left', 'bar accepts valid positions')
|
||||||
assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid 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 fs = require('fs')
|
||||||
const weather = requireFromRoot('shell/plugins/panels/weather/Model.js')
|
const weather = requireFromRoot('shell/plugins/panels/weather/Model.js')
|
||||||
const panelSource = fs.readFileSync(root + '/shell/plugins/panels/weather/Panel.qml', 'utf8')
|
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(
|
assertDeepEqual(
|
||||||
weather.parseWeatherStatus('{"text":"☀","class":"sunny"}'),
|
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')
|
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 }, ''), 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')
|
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(
|
assert(
|
||||||
panelSource.includes('text: root.label || "—"'),
|
panelSource.includes('text: root.label || "—"'),
|
||||||
'weather hero and bar use the same resolved icon'
|
'weather hero and bar use the same resolved icon'
|
||||||
|
|||||||
Reference in New Issue
Block a user