Files
OmarchyClockBar/Editor.qml
T
HarveyandClaude Opus 5 8364fe2141 Seconds per clock, and type that scales with the bar height
Seconds move from one bar-wide switch to a per-clock setting. A clock's own
`showSeconds` wins, the top-level one is the default for clocks with no
opinion, and the centre clock gets `local.showSeconds` since it has no entry in
`clocks[]`. An explicit false on a clock beats a true default, so the check is
for the property's presence rather than its truthiness.

The tick rate follows what is actually displayed: `anyShowsSeconds()` decides
between a per-second and a per-minute SystemClock, so a bar showing no seconds
does not wake the shell sixty times as often to redraw an identical minute.

`barSize` is now editable from the popup, and everything drawn on the bar is
sized against the theme's own bar height rather than pinned to the theme's
12px type. A taller bar carries proportionally larger text, gaps and rule; at
the theme's own height the scale is exactly 1, so nothing moves. Heights are
clamped to 12-120 rather than rejected, so a value the editor can produce can
never be one the config layer silently discards.

Fixes a latent bug this surfaced: updateRow() rebuilt a draft row from a fixed
pair of fields, so editing a clock's label would have thrown away its own
seconds setting. It now copies every key the row had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
2026-09-04 18:02:36 +02:00

470 lines
15 KiB
QML

import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "ClockModel.js" as Model
// The click-to-edit surface for the clock bar: add, rename, retime and remove
// clocks without touching JSON.
//
// It edits a draft copy and only writes on Save, so an abandoned edit leaves
// the file untouched — and because the write goes through the same Config
// object the file watcher feeds, the bar updates from disk either way. There
// is no second source of truth for the two config routes to disagree about.
Item {
id: root
property var configFile: null
property var zones: null
readonly property bool opened: controller.open
property var draft: []
property int workStart: 8
property int workEnd: 18
property bool dimOutside: true
property bool defaultSeconds: false
property bool localSeconds: false
property int barSize: 0
readonly property color background: Color.popups.background
readonly property color foreground: Color.popups.text
readonly property color muted: Color.muted
readonly property color accent: Color.accent
readonly property color urgent: Color.urgent
readonly property var zoneOptions: {
var list = root.zones ? root.zones.allZones : []
var out = []
for (var i = 0; i < list.length; i++) out.push({ value: list[i], label: list[i] })
return out
}
QtObject {
id: controller
property bool open: false
}
function open() {
loadDraft()
controller.open = true
}
function close() { controller.open = false }
// A deep copy: editing the live config array in place would push
// half-finished rows straight onto the bar.
function loadDraft() {
var cfg = root.configFile ? root.configFile.config : null
var clocks = cfg && Array.isArray(cfg.clocks) ? cfg.clocks : []
var copy = []
for (var i = 0; i < clocks.length; i++) {
var row = { label: String(clocks[i].label || ""), timezone: String(clocks[i].timezone || "") }
if (typeof clocks[i].showSeconds === "boolean") row.showSeconds = clocks[i].showSeconds
copy.push(row)
}
root.draft = copy
if (cfg && cfg.workingHours) {
root.workStart = Number(cfg.workingHours.start)
root.workEnd = Number(cfg.workingHours.end)
}
root.dimOutside = cfg ? cfg.dimOutsideWorkingHours !== false : true
root.defaultSeconds = cfg ? cfg.showSeconds === true : false
root.localSeconds = Model.showsSeconds(cfg ? cfg.local : null, root.defaultSeconds)
root.barSize = cfg && cfg.barSize > 0 ? cfg.barSize : 0
}
// What a row's toggle shows: its own setting when it has one, otherwise the
// bar-wide default it is inheriting.
function rowShowsSeconds(row) { return Model.showsSeconds(row, root.defaultSeconds) }
// QML only re-renders a var array when the reference changes, so every
// mutation rebuilds it rather than editing in place.
function replaceDraft(next) { root.draft = next }
// Copies every key the row already had. Rebuilding it from a fixed pair of
// fields silently dropped anything else on it — editing a label would have
// thrown away that clock's own seconds setting.
function updateRow(index, key, value) {
var next = root.draft.slice()
if (!next[index]) return
var row = {}
for (var k in next[index]) row[k] = next[index][k]
row[key] = value
next[index] = row
replaceDraft(next)
}
function removeRow(index) {
var next = root.draft.slice()
next.splice(index, 1)
replaceDraft(next)
}
function addRow() {
var next = root.draft.slice()
next.push({ label: "", timezone: "" })
replaceDraft(next)
}
// A row is only saveable once it names a zone; the label falls back to the
// zone's own name so a clock can never render blank.
function saveableRows() {
var out = []
for (var i = 0; i < root.draft.length; i++) {
var tz = String(root.draft[i].timezone || "").replace(/^\s+|\s+$/g, "")
if (!tz) continue
var label = String(root.draft[i].label || "").replace(/^\s+|\s+$/g, "")
var row = { label: label || tz.split("/").pop().replace(/_/g, " "), timezone: tz }
// Only clocks the user actually set keep an explicit flag; the rest go
// on inheriting the bar-wide default.
if (typeof root.draft[i].showSeconds === "boolean") row.showSeconds = root.draft[i].showSeconds
out.push(row)
}
return out
}
function save() {
if (!root.configFile) return
var cfg = root.configFile.config
root.configFile.save({
version: 1,
clocks: saveableRows(),
workingHours: {
start: root.workStart,
end: root.workEnd,
workdays: cfg && cfg.workingHours ? cfg.workingHours.workdays : [1, 2, 3, 4, 5]
},
dimOutsideWorkingHours: root.dimOutside,
showSeconds: root.defaultSeconds,
local: { showSeconds: root.localSeconds },
barSize: root.barSize
})
close()
}
// ------------------------------------------------------------ the surface
PanelWindow {
id: window
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "nerdnation-clockbar-editor"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.45)
}
// Click-away closes; the card swallows its own clicks so a click inside
// does not fall through to this one.
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
BorderSurface {
id: card
anchors.centerIn: parent
width: Style.space(560)
height: Math.min(parent.height - Style.space(80), content.implicitHeight + card.contentTopInset + card.contentBottomInset)
radius: Style.cornerRadius
color: root.background
borderSpec: Border.localOrSurfaceSpec("popups", "border", Color.popups.border, Color.popups.border, Math.max(1, Style.space(2)))
padding: Style.spacing.popupPadding
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
anchors.fill: parent
anchors.margins: card.padding
focus: true
Keys.onEscapePressed: root.close()
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.space(10)
PanelSectionHeader {
Layout.fillWidth: true
text: "Clock bar"
}
Text {
Layout.fillWidth: true
text: "Clocks are placed automatically: behind your local time to the left, ahead to the right."
color: root.muted
wrapMode: Text.WordWrap
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
// ------------------------------------------------------- the rows
Flickable {
Layout.fillWidth: true
// Tall enough for the rows it has, up to a cap that keeps the card
// on screen with a long clock list; past that it scrolls.
Layout.preferredHeight: Math.min(rows.implicitHeight, Style.space(300))
contentHeight: rows.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ColumnLayout {
id: rows
width: parent.width
spacing: Style.space(6)
Repeater {
model: root.draft
delegate: RowLayout {
id: row
required property int index
required property var modelData
Layout.fillWidth: true
spacing: Style.space(8)
TextField {
Layout.preferredWidth: Style.space(66)
text: row.modelData.label
placeholderText: "Label"
foreground: root.foreground
accent: root.accent
onEditingFinished: root.updateRow(row.index, "label", text)
}
SearchableDropdown {
Layout.fillWidth: true
showLabel: false
value: row.modelData.timezone
options: root.zoneOptions
placeholderText: "Search timezones..."
triggerLabel: row.modelData.timezone || "Choose a timezone"
foreground: root.foreground
accent: root.accent
onChanged: function (value) { root.updateRow(row.index, "timezone", value) }
}
// The live offset, so the effect of a choice is visible
// before saving rather than only once the bar redraws.
Text {
Layout.preferredWidth: Style.space(52)
horizontalAlignment: Text.AlignRight
text: {
var off = root.zones && root.zones.offsets ? root.zones.offsets[row.modelData.timezone] : null
return off ? off : (row.modelData.timezone ? "—" : "")
}
color: root.muted
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
Button {
text: "SS"
bordered: true
selected: root.rowShowsSeconds(row.modelData)
tooltipText: root.rowShowsSeconds(row.modelData)
? "Showing seconds — click for HH:MM"
: "Show seconds on this clock"
foreground: root.foreground
accent: root.accent
onClicked: root.updateRow(row.index, "showSeconds", !root.rowShowsSeconds(row.modelData))
}
Button {
iconText: "󰅙"
tooltipText: "Remove this clock"
foreground: root.foreground
accent: root.urgent
onClicked: root.removeRow(row.index)
}
}
}
Text {
Layout.fillWidth: true
visible: root.draft.length === 0
text: "No clocks yet — add one below."
color: root.muted
font.family: Style.font.family
font.pixelSize: Style.font.body
}
}
}
Button {
Layout.alignment: Qt.AlignLeft
text: "+ Add clock"
bordered: true
foreground: root.foreground
accent: root.accent
onClicked: root.addRow()
}
PanelSeparator { Layout.fillWidth: true }
// -------------------------------------------------- working hours
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
ToggleSwitch {
checked: root.dimOutside
foreground: root.foreground
accent: root.accent
onToggled: root.dimOutside = !root.dimOutside
}
Text {
Layout.fillWidth: true
text: "Dim clocks outside working hours"
color: root.foreground
elide: Text.ElideRight
font.family: Style.font.family
font.pixelSize: Style.font.body
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
opacity: root.dimOutside ? 1.0 : 0.45
Text {
Layout.fillWidth: true
text: "Working hours (local to each clock)"
color: root.muted
elide: Text.ElideRight
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
NumberField {
Layout.preferredWidth: Style.spacing.numberFieldWidth
enabled: root.dimOutside
value: root.workStart
from: 0
to: 24
foreground: root.foreground
accent: root.accent
onModified: function (v) { root.workStart = v }
}
Text {
text: "to"
color: root.muted
font.family: Style.font.family
font.pixelSize: Style.font.body
}
NumberField {
Layout.preferredWidth: Style.spacing.numberFieldWidth
enabled: root.dimOutside
value: root.workEnd
from: 0
to: 24
foreground: root.foreground
accent: root.accent
onModified: function (v) { root.workEnd = v }
}
}
PanelSeparator { Layout.fillWidth: true }
PanelSectionHeader {
Layout.fillWidth: true
text: "Bar"
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: "Seconds on your local clock"
color: root.foreground
elide: Text.ElideRight
font.family: Style.font.family
font.pixelSize: Style.font.body
}
ToggleSwitch {
checked: root.localSeconds
foreground: root.foreground
accent: root.accent
onToggled: root.localSeconds = !root.localSeconds
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: "Bar height — 0 follows the theme. Text scales with it."
color: root.muted
elide: Text.ElideRight
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
NumberField {
Layout.preferredWidth: Style.spacing.numberFieldWidth
value: root.barSize
from: 0
to: 120
foreground: root.foreground
accent: root.accent
onModified: function (v) { root.barSize = v }
}
}
PanelSeparator { Layout.fillWidth: true }
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: root.configFile ? root.configFile.path : ""
color: root.muted
elide: Text.ElideMiddle
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
Button {
text: "Cancel"
bordered: true
foreground: root.foreground
accent: root.accent
onClicked: root.close()
}
Button {
text: "Save"
bordered: true
selected: true
foreground: root.foreground
accent: root.accent
onClicked: root.save()
}
}
}
}
}
}
}