Add a calendar popup to the clock
Clicking the clock reveals a month grid with ISO week numbers, a year progress meter, and month stepping. Right click walks the common label formats and writes the chosen one back to shell.json, so the bar shows what the config stores. The week start toggles from the grid's "W" heading and persists as weekStartDay, defaulting to the locale's own first day. Rich popup widgets live in their own plugin directories, so the clock moves out of bar/widgets/ into panels/clock/. The id is unchanged, so existing layouts and centerAnchor keep working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c2610c3788
commit
e2d655d265
@@ -22,6 +22,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
||||
| Notifications | `omarchy.notifications` | `service` | `notifications/Service.qml` |
|
||||
| Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` |
|
||||
| Bluetooth | `omarchy.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` |
|
||||
| Clock | `omarchy.clock` | `bar-widget` | `panels/clock/BarWidget.qml` |
|
||||
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
|
||||
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
||||
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
||||
@@ -37,7 +38,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
||||
| Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` |
|
||||
|
||||
First-party bar-only widgets also carry manifests next to their QML files,
|
||||
e.g. `bar/widgets/Clock.manifest.json`. Rich popup widgets live in their
|
||||
e.g. `bar/widgets/Workspaces.manifest.json`. Rich popup widgets live in their
|
||||
own plugin directories, each with its own `manifest.json`.
|
||||
|
||||
## Bar
|
||||
|
||||
@@ -56,7 +56,7 @@ Example `shell.json` (bar subtree only shown):
|
||||
|---|---|---|
|
||||
| `omarchy.menu` | Omarchy menu launcher | left = menu · right = terminal |
|
||||
| `omarchy.workspaces` | Hyprland workspace switcher | left = focus workspace |
|
||||
| `omarchy.clock` | Date/time label | left = alternate format · right = timezone selector |
|
||||
| `omarchy.clock` | Date/time label + popup with a month grid, ISO week numbers, and month stepping | left = popup · right = cycle label format · middle = timezone selector |
|
||||
| `omarchy.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup |
|
||||
| `omarchy.indicators` | Manual state indicators | left = indicator action |
|
||||
| `omarchy.system-update` | Available update indicator | left = update |
|
||||
@@ -166,7 +166,7 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o
|
||||
- `bar.requestPopout(owner)` / `bar.releasePopout(owner)` — one-popup-at-a-time coordinator
|
||||
|
||||
First-party bar widgets are manifest-backed just like third-party widgets.
|
||||
Simple widgets carry sibling manifests such as `widgets/Clock.manifest.json`;
|
||||
Simple widgets carry sibling manifests such as `widgets/Workspaces.manifest.json`;
|
||||
richer popup plugins live in feature directories such as `../panels/audio/`,
|
||||
`../panels/network/`, and `../model-usage/`; and feature plugins such as
|
||||
`omarchy.menu` and `omarchy.media` declare their bar-widget entry points in their own
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.clock"
|
||||
|
||||
property bool alt: false
|
||||
property date displayDate: clock.date
|
||||
|
||||
readonly property string activeFormat: alt
|
||||
? (vertical ? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy") : setting("formatAlt", "d MMMM 'W'ww yyyy"))
|
||||
: (vertical ? setting("verticalFormat", "HH\n—\nmm") : setting("format", "dddd HH:mm"))
|
||||
readonly property string displayText: formatted(displayDate)
|
||||
readonly property var verticalLines: displayText.split("\n")
|
||||
|
||||
function refresh() {
|
||||
displayDate = new Date()
|
||||
}
|
||||
|
||||
function isoWeek(date) {
|
||||
var d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||||
var day = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - day)
|
||||
var yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
||||
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7)
|
||||
}
|
||||
|
||||
function isoWeekLiteral(date) {
|
||||
var week = isoWeek(date)
|
||||
return (week < 10 ? "0" : "") + week
|
||||
}
|
||||
|
||||
function formatted(date) {
|
||||
return Qt.formatDateTime(date, activeFormat.replace(/ww/g, isoWeekLiteral(date)))
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Minutes
|
||||
onDateChanged: root.displayDate = date
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "omarchy.clock"
|
||||
function refresh(): void { root.broadcast("refresh") }
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.vertical ? "" : root.displayText
|
||||
labelVisible: !root.vertical
|
||||
hasVisualContent: root.vertical ? root.verticalLines.length > 0 : text !== ""
|
||||
fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1
|
||||
horizontalMargin: 8.75
|
||||
verticalPadding: 8.75
|
||||
onPressed: function(button) {
|
||||
if (!root.bar) return
|
||||
if (button === Qt.RightButton) root.bar.run("omarchy-menu-timezone")
|
||||
else root.alt = !root.alt
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: root.vertical
|
||||
anchors.fill: parent
|
||||
|
||||
Repeater {
|
||||
model: root.verticalLines
|
||||
|
||||
OpticalGlyph {
|
||||
required property string modelData
|
||||
width: button.width
|
||||
height: Style.bar.iconSlot
|
||||
text: modelData
|
||||
fontFamily: button.fontFamily
|
||||
fontSize: modelData.length > 3
|
||||
? button.fontSize * 0.9
|
||||
: button.fontSize
|
||||
color: button.foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
|
||||
// Date/time label for the bar, and the host for the calendar popup.
|
||||
//
|
||||
// Left click reveals the calendar — asking "what is the date?" is what a
|
||||
// click on a clock means — right click walks the common label formats, and
|
||||
// middle click opens the timezone picker.
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.clock"
|
||||
|
||||
property date displayDate: clock.date
|
||||
|
||||
readonly property string configuredFormat: vertical
|
||||
? setting("verticalFormat", "HH\n—\nmm")
|
||||
: setting("format", "dddd HH:mm")
|
||||
readonly property string configuredAltFormat: vertical
|
||||
? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy")
|
||||
: setting("formatAlt", "d MMMM 'W'ww yyyy")
|
||||
|
||||
readonly property var formatRing: Model.clockFormatRing(configuredFormat, configuredAltFormat, Model.clockFormats(vertical))
|
||||
|
||||
// What the bar shows is what shell.json stores, so a cycled format is the
|
||||
// format from then on rather than something that reverts on restart.
|
||||
readonly property string activeFormat: configuredFormat
|
||||
readonly property string displayText: formatted(displayDate)
|
||||
readonly property var verticalLines: displayText.split("\n")
|
||||
|
||||
function refresh() {
|
||||
displayDate = new Date()
|
||||
if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh()
|
||||
}
|
||||
|
||||
function cycleFormat() {
|
||||
var current = String(configuredFormat)
|
||||
var next = Model.nextClockFormat(formatRing, current)
|
||||
if (next === "" || next === current) return
|
||||
|
||||
var entry = { id: root.moduleName }
|
||||
for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key]
|
||||
entry[vertical ? "verticalFormat" : "format"] = next
|
||||
|
||||
// Applied locally first so the label changes on the click itself; the
|
||||
// shell.json write comes back through the bar as the same value.
|
||||
root.settings = entry
|
||||
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
|
||||
root.bar.shell.updateEntryInline(root.moduleName, entry)
|
||||
}
|
||||
|
||||
function formatted(date) {
|
||||
return Qt.formatDateTime(date, activeFormat.replace(/ww/g, Model.isoWeekLiteral(date.getFullYear(), date.getMonth(), date.getDate())))
|
||||
}
|
||||
|
||||
// ---- Calendar popup. Shape contract for shell.summon/hide/toggle
|
||||
// routing: Bar.findPanelWidget requires open/close/opened on the
|
||||
// bar-widget root.
|
||||
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
|
||||
|
||||
function open() {
|
||||
if (panelLoader.item) panelLoader.item.open()
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (panelLoader.item) panelLoader.item.close()
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panelLoader.item) panelLoader.item.toggle()
|
||||
}
|
||||
|
||||
// The clock fills more slot than it paints a mark for, at both
|
||||
// orientations: horizontally it is a text label in a padded slot, so the
|
||||
// dot takes the label width; vertically it is a stack of icon-sized lines,
|
||||
// so the dot takes one line — the same mark every icon widget gets, rather
|
||||
// than a rule running the height of the whole stack.
|
||||
readonly property real openPanelIndicatorWidth: button.labelWidth
|
||||
readonly property real openPanelIndicatorHeight: Math.max(Style.space(10), Math.round(Style.bar.iconSlot * 0.55))
|
||||
|
||||
// Forwarded so this widget can stand in for the panel as the bar's popout
|
||||
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
|
||||
// KeyboardPanel reads popoutSwitchClosing back off its owner.
|
||||
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
|
||||
|
||||
function closeForPopoutSwitch() {
|
||||
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
|
||||
}
|
||||
|
||||
function injectPanel() {
|
||||
var target = panelLoader.item
|
||||
if (!target) return
|
||||
if ("bar" in target) target.bar = root.bar
|
||||
if ("settings" in target) target.settings = root.settings
|
||||
if ("anchorItem" in target) target.anchorItem = button
|
||||
if ("hostWidget" in target) target.hostWidget = root
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
onBarChanged: injectPanel()
|
||||
onSettingsChanged: injectPanel()
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Minutes
|
||||
onDateChanged: root.displayDate = date
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: panelLoader
|
||||
active: true
|
||||
source: Qt.resolvedUrl("Panel.qml")
|
||||
visible: false
|
||||
onLoaded: {
|
||||
root.injectPanel()
|
||||
Qt.callLater(root.injectPanel)
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "omarchy.clock"
|
||||
|
||||
function refresh(): void { root.broadcast("refresh") }
|
||||
function cycleFormat(): void { root.cycleFormat() }
|
||||
function open(): void { root.open() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.open() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.togglePanel() }
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.vertical ? "" : root.displayText
|
||||
labelVisible: !root.vertical
|
||||
hasVisualContent: root.vertical ? root.verticalLines.length > 0 : text !== ""
|
||||
fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1
|
||||
horizontalMargin: 8.75
|
||||
verticalPadding: 8.75
|
||||
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.RightButton) root.cycleFormat()
|
||||
else if (b === Qt.MiddleButton) { if (root.bar) root.bar.run("omarchy-menu-timezone") }
|
||||
else root.togglePanel()
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: root.vertical
|
||||
anchors.fill: parent
|
||||
|
||||
Repeater {
|
||||
model: root.verticalLines
|
||||
|
||||
OpticalGlyph {
|
||||
required property string modelData
|
||||
width: button.width
|
||||
height: Style.bar.iconSlot
|
||||
text: modelData
|
||||
fontFamily: button.fontFamily
|
||||
fontSize: modelData.length > 3
|
||||
? button.fontSize * 0.9
|
||||
: button.fontSize
|
||||
color: button.foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
monthGrid: monthGrid,
|
||||
stepMonth: stepMonth,
|
||||
clockFormats: clockFormats,
|
||||
clockFormatRing: clockFormatRing,
|
||||
nextClockFormat: nextClockFormat,
|
||||
isoWeekLiteral: isoWeekLiteral
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
|
||||
// The clock's calendar popup: a month grid with ISO week numbers, built to
|
||||
// sit beside the weather panel — same hero-over-detail composition, same
|
||||
// spacing scale, same small-caps labels.
|
||||
//
|
||||
// The grid is a read-out rather than a picker: today is the only marked
|
||||
// day, and the only thing that moves is which month is on screen —
|
||||
// chevrons, the scroll wheel, and the arrow keys all step it.
|
||||
//
|
||||
// BarWidget.qml owns the bar label and hands this panel the button to
|
||||
// anchor against.
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "omarchy.clock"
|
||||
ipcTarget: "omarchy.clock"
|
||||
manageIpc: false
|
||||
|
||||
property var anchorItem: null
|
||||
|
||||
// The bar tracks the widget mounted in its slot — BarWidget.qml — not this
|
||||
// nested panel. Everything the bar identifies a panel by has to be that
|
||||
// widget: the popout coordinator (and with it the open-panel dot under the
|
||||
// pill) compares against `slot.activeItem`, and switchPanelFrom looks the
|
||||
// slot up the same way.
|
||||
property var hostWidget: null
|
||||
readonly property var barIdentity: hostWidget || root
|
||||
|
||||
// ---- Today. SystemClock keeps this honest across midnight so the
|
||||
// highlight rolls over without the panel being reopened.
|
||||
property date today: new Date()
|
||||
readonly property string todayKey: Model.keyForDate(today)
|
||||
|
||||
// The month on screen. Stepping moves this and nothing else: the grid is
|
||||
// a read-out, not a picker, so there is no per-day cursor to keep in sync.
|
||||
property int viewYear: today.getFullYear()
|
||||
property int viewMonth: today.getMonth()
|
||||
|
||||
readonly property date viewDate: new Date(viewYear, viewMonth, 1)
|
||||
readonly property bool viewingCurrentMonth: viewYear === today.getFullYear() && viewMonth === today.getMonth()
|
||||
|
||||
// Pinned to today, not to the month being browsed — stepping through the
|
||||
// calendar does not change how much of the year is gone.
|
||||
readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
|
||||
// Unset falls through to the locale's own first day, so a fresh install
|
||||
// starts out matching the rest of the desktop rather than a hardcoded
|
||||
// convention. Clicking the grid's "W" heading writes the choice back to
|
||||
// shell.json.
|
||||
readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek)
|
||||
readonly property string nextWeekStartLabel: Qt.locale().dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat)
|
||||
readonly property var weekdays: Model.weekdayOrder(weekStart)
|
||||
readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey)
|
||||
|
||||
|
||||
// Guarded so the widget renders before the bar is injected (the bar-widget
|
||||
// contract instantiates it bare).
|
||||
readonly property color contentForeground: bar ? bar.foreground : Color.foreground
|
||||
readonly property string contentFontFamily: bar ? bar.fontFamily : Style.font.family
|
||||
|
||||
readonly property int cellWidth: Style.space(52)
|
||||
readonly property int cellHeight: Style.space(34)
|
||||
readonly property int cellSpacing: Style.space(2)
|
||||
readonly property int weekColumnWidth: Style.space(32)
|
||||
readonly property int gutterWidth: Style.space(14)
|
||||
|
||||
function open() {
|
||||
setCenterHoverRevealSuppressed(true)
|
||||
refresh()
|
||||
root.controller.show()
|
||||
}
|
||||
|
||||
function close() {
|
||||
// Not when another panel is taking over: it has already set the shared
|
||||
// flag for itself, and clearing it here would leave the incoming panel
|
||||
// open with the center indicators revealed behind it.
|
||||
if (!root.popoutSwitchClosing) setCenterHoverRevealSuppressed(false)
|
||||
root.controller.hide()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.opened) root.close()
|
||||
else root.open()
|
||||
}
|
||||
|
||||
function switchPanel(direction) {
|
||||
if (root.bar && typeof root.bar.switchPanelFrom === "function")
|
||||
return root.bar.switchPanelFrom(root.barIdentity, direction)
|
||||
return false
|
||||
}
|
||||
|
||||
// Summoning by hotkey moves no pointer, so a hover the bar was still
|
||||
// holding must not keep the center indicators revealed behind the panel.
|
||||
function setCenterHoverRevealSuppressed(value) {
|
||||
if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||
root.bar.centerHoverRevealSuppressed = value
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
root.today = new Date()
|
||||
root.goToToday()
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
root.viewYear = today.getFullYear()
|
||||
root.viewMonth = today.getMonth()
|
||||
}
|
||||
|
||||
function moveMonth(delta) {
|
||||
var next = Model.stepMonth(viewYear, viewMonth, delta)
|
||||
root.viewYear = next.year
|
||||
root.viewMonth = next.month
|
||||
}
|
||||
|
||||
function moveYear(delta) {
|
||||
moveMonth(delta * 12)
|
||||
}
|
||||
|
||||
function setWeekStart(day) {
|
||||
var next = Model.normalizedWeekStart(day, root.weekStart)
|
||||
if (next === root.weekStart) return
|
||||
|
||||
var entry = { id: root.moduleName }
|
||||
for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key]
|
||||
entry.weekStartDay = Model.weekStartSettingName(next)
|
||||
|
||||
// Applied locally first so the grid reflows on the click itself; the
|
||||
// shell.json write comes back through the bar as the same value. With
|
||||
// no writable entry (the widget is not in the layout) it stays a
|
||||
// session-only preference rather than doing nothing.
|
||||
root.settings = entry
|
||||
// The host widget builds its own entry when the label format is cycled.
|
||||
// Until shell.json round-trips it would be working from a copy without
|
||||
// this key, and would write the week start straight back out.
|
||||
if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry
|
||||
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
|
||||
root.bar.shell.updateEntryInline(root.moduleName, entry)
|
||||
}
|
||||
|
||||
function toggleWeekStart() {
|
||||
setWeekStart(Model.toggledWeekStart(root.weekStart))
|
||||
}
|
||||
|
||||
// Locale short day names, trimmed of the trailing period some locales
|
||||
// carry ("man." -> "MAN") so the header row stays a clean band of caps.
|
||||
function weekdayLabel(weekday) {
|
||||
return String(Qt.locale().dayName(weekday, Locale.ShortFormat)).replace(/\.$/, "").toUpperCase()
|
||||
}
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Minutes
|
||||
onDateChanged: {
|
||||
if (Model.keyForDate(clock.date) === String(root.todayKey)) return
|
||||
var followToday = root.viewingCurrentMonth
|
||||
root.today = clock.date
|
||||
if (followToday) root.goToToday()
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: root.anchorItem
|
||||
owner: root.barIdentity
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
centerOnBar: true
|
||||
focusTarget: keyCatcher
|
||||
contentWidth: panel.fittedContentWidth(Style.space(560))
|
||||
contentHeight: panel.fittedContentHeight(calendarColumn.implicitHeight)
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
onMoveRequested: function(dx, dy) {
|
||||
if (dx !== 0) root.moveMonth(dx)
|
||||
if (dy !== 0) root.moveYear(dy)
|
||||
}
|
||||
onActivateRequested: root.goToToday()
|
||||
onCloseRequested: root.close()
|
||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||
onTextKey: function(t) {
|
||||
if (t === "[") root.moveMonth(-1)
|
||||
else if (t === "]") root.moveMonth(1)
|
||||
else if (t === "{") root.moveYear(-1)
|
||||
else if (t === "}") root.moveYear(1)
|
||||
else if (t === "t" || t === "T") root.goToToday()
|
||||
else if (t === "w" || t === "W") root.toggleWeekStart()
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: calendarScroll
|
||||
anchors.fill: parent
|
||||
contentWidth: calendarColumn.width
|
||||
contentHeight: calendarColumn.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
interactive: contentHeight > height || contentWidth > width
|
||||
|
||||
Column {
|
||||
id: calendarColumn
|
||||
// Never narrower than the grid. The popup width is capped to what
|
||||
// the screen allows, and a fixed seven-column grid would otherwise
|
||||
// lose its last days off the edge instead of scrolling.
|
||||
width: Math.max(calendarScroll.width, gridColumn.width)
|
||||
spacing: Style.space(8)
|
||||
|
||||
// ---- Hero: today, centered. Once the view has stepped back
|
||||
// it is also the way home — clicking the date you are
|
||||
// looking for beats hunting for a reset button.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: heroRow.height
|
||||
|
||||
Row {
|
||||
id: heroRow
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Style.space(22)
|
||||
|
||||
Text {
|
||||
// Baseline-aligned, not center-aligned: "July 26" carries a
|
||||
// descender, so centering the two boxes leaves the icon
|
||||
// sitting visibly low against the digits.
|
||||
anchors.baseline: heroDate.baseline
|
||||
text: ""
|
||||
color: heroMouse.containsMouse
|
||||
? Style.hoverStateColor(root.contentForeground, Color.accent)
|
||||
: root.contentForeground
|
||||
font.family: root.contentFontFamily
|
||||
// Decorative, and deliberately outside the Style.font.*
|
||||
// scale. Sized so the glyph reads at the cap height of the
|
||||
// date beside it rather than towering over it.
|
||||
font.pixelSize: 48
|
||||
}
|
||||
|
||||
Text {
|
||||
id: heroDate
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Qt.formatDate(root.today, "MMMM d")
|
||||
color: heroMouse.containsMouse
|
||||
? Style.hoverStateColor(root.contentForeground, Color.accent)
|
||||
: root.contentForeground
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: 52
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: heroMouse
|
||||
x: heroRow.x
|
||||
y: heroRow.y
|
||||
width: heroRow.width
|
||||
height: heroRow.height
|
||||
enabled: !root.viewingCurrentMonth
|
||||
hoverEnabled: enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.goToToday()
|
||||
|
||||
PanelToolTip {
|
||||
visible: heroMouse.containsMouse
|
||||
text: "Back to today"
|
||||
fontFamily: root.contentFontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Year progress, doubling as the rule under the hero:
|
||||
// a plain hairline said nothing, and whole days done
|
||||
// over days in the year says the same thing louder.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: yearBlock.y + yearBlock.height
|
||||
|
||||
Item {
|
||||
id: yearBlock
|
||||
y: Style.space(6)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: gridColumn.width
|
||||
height: Math.max(yearLabel.implicitHeight, Style.space(10))
|
||||
|
||||
Text {
|
||||
id: yearLabel
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.today.getFullYear()
|
||||
color: Qt.darker(root.contentForeground, 1.5)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
id: yearPercent
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.yearDonePercent + "%"
|
||||
color: root.contentForeground
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: yearTrack
|
||||
anchors.left: yearLabel.right
|
||||
anchors.right: yearPercent.left
|
||||
anchors.leftMargin: Style.space(12)
|
||||
anchors.rightMargin: Style.space(12)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: Style.space(6)
|
||||
radius: Style.cornerRadius > 0 ? height / 2 : 0
|
||||
color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12)
|
||||
|
||||
Rectangle {
|
||||
width: Math.round(parent.width * root.yearDone)
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: Style.selectedStateColor(root.contentForeground, Color.accent)
|
||||
|
||||
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Month grid: week numbers down a gutter on the left, then
|
||||
// the seven day columns. Always six rows, so the popup is
|
||||
// exactly as tall in February as it is in August.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: gridColumn.y + gridColumn.height
|
||||
|
||||
WheelHandler {
|
||||
onWheel: function(event) {
|
||||
// Horizontal wheels and touchpad side-scrolls report y === 0;
|
||||
// without this they would every one read as "next month".
|
||||
if (event.angleDelta.y === 0) return
|
||||
root.moveMonth(event.angleDelta.y > 0 ? -1 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: gridColumn
|
||||
// The meter above is a solid rule; the grid needs room to
|
||||
// read as its own block rather than hanging off it.
|
||||
y: Style.space(18)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Style.space(3)
|
||||
|
||||
Row {
|
||||
id: headerRow
|
||||
spacing: root.cellSpacing
|
||||
|
||||
// The week-number heading doubles as the week-start toggle.
|
||||
// It is the one control in the panel whose meaning is not
|
||||
// self-evident, so it carries a tooltip naming the day the
|
||||
// click will switch to.
|
||||
Rectangle {
|
||||
width: root.weekColumnWidth
|
||||
height: Style.space(16)
|
||||
radius: Style.cornerRadius
|
||||
color: weekStartMouse.containsMouse
|
||||
? Style.hoverFillFor(root.contentForeground, Color.accent)
|
||||
: "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "W"
|
||||
color: weekStartMouse.containsMouse
|
||||
? Style.hoverStateColor(root.contentForeground, Color.accent)
|
||||
: Qt.darker(root.contentForeground, 1.9)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: weekStartMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleWeekStart()
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: weekStartMouse.containsMouse
|
||||
text: "Start weeks on " + root.nextWeekStartLabel
|
||||
fontFamily: root.contentFontFamily
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: root.gutterWidth
|
||||
height: Style.space(16)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.weekdays
|
||||
|
||||
Text {
|
||||
required property var modelData
|
||||
width: root.cellWidth
|
||||
height: Style.space(16)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: root.weekdayLabel(modelData)
|
||||
color: Qt.darker(root.contentForeground, 1.5)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.weeks
|
||||
|
||||
Row {
|
||||
required property var modelData
|
||||
spacing: root.cellSpacing
|
||||
|
||||
Text {
|
||||
width: root.weekColumnWidth
|
||||
height: root.cellHeight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: modelData.week
|
||||
color: Qt.darker(root.contentForeground, 1.9)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Item {
|
||||
width: root.gutterWidth
|
||||
height: root.cellHeight
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: modelData.days
|
||||
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
|
||||
width: root.cellWidth
|
||||
height: root.cellHeight
|
||||
radius: Style.cornerRadius
|
||||
// Today is outlined, not filled: a lit-up block shouts
|
||||
// over a grid this quiet.
|
||||
color: "transparent"
|
||||
border.width: modelData.today ? Style.spacing.hairline : 0
|
||||
border.color: Style.normalBorderFor(root.contentForeground, Color.accent)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.day
|
||||
color: modelData.inMonth
|
||||
? (modelData.weekend ? Qt.darker(root.contentForeground, 1.45) : root.contentForeground)
|
||||
: Qt.darker(root.contentForeground, 2.2)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
font.bold: modelData.today
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hairline down the week-number gutter, drawn only beside the
|
||||
// day rows so it does not cut through the header band.
|
||||
Rectangle {
|
||||
x: gridColumn.x + root.weekColumnWidth + root.cellSpacing + Math.round((root.gutterWidth - width) / 2)
|
||||
y: gridColumn.y + headerRow.height + gridColumn.spacing
|
||||
width: Style.spacing.hairline
|
||||
height: gridColumn.height - headerRow.height - gridColumn.spacing
|
||||
color: root.contentForeground
|
||||
opacity: 0.1
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Month stepping, spanning the grid it drives. The chevrons
|
||||
// sit on the grid's outer bounds, the same edges the year
|
||||
// rail above uses, so the row reads as the panel's other
|
||||
// full-width rail instead of a cluster floating in space.
|
||||
// The label is centered and fixed-width, so it holds still
|
||||
// from "MAY" to "SEPTEMBER".
|
||||
Item {
|
||||
width: parent.width
|
||||
height: monthNav.height
|
||||
|
||||
Item {
|
||||
id: monthNav
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: gridColumn.width
|
||||
height: monthLabel.implicitHeight + Style.space(10)
|
||||
|
||||
Text {
|
||||
id: monthLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Fixed width so the chevrons hold still between a
|
||||
// "MAY 2026" and a "SEPTEMBER 2026".
|
||||
width: Style.space(130)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase()
|
||||
color: Qt.darker(root.contentForeground, 1.4)
|
||||
font.family: root.contentFontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
PanelActionButton {
|
||||
// Pulled out by the button's own padding so the glyph, not
|
||||
// its hit box, lines up with the "2026" on the year rail.
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconText: ""
|
||||
tooltipText: "Previous month"
|
||||
foreground: root.contentForeground
|
||||
fontFamily: root.contentFontFamily
|
||||
onClicked: root.moveMonth(-1)
|
||||
}
|
||||
|
||||
PanelActionButton {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: -Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconText: ""
|
||||
tooltipText: "Next month"
|
||||
foreground: root.contentForeground
|
||||
fontFamily: root.contentFontFamily
|
||||
onClicked: root.moveMonth(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -4,16 +4,16 @@
|
||||
"name": "Clock",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Day/time label; click to toggle alternate format",
|
||||
"description": "Date/time label with a calendar popup",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Clock.qml"
|
||||
"barWidget": "BarWidget.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Clock",
|
||||
"description": "Day/time label; click to toggle alternate format",
|
||||
"description": "Date/time label with a calendar popup",
|
||||
"category": "Time",
|
||||
"allowMultiple": false
|
||||
}
|
||||
Reference in New Issue
Block a user