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
470 lines
15 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|