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. That rebuild is also // why edits are committed on focus loss rather than on every keystroke: // replacing the array re-creates the row delegates, which would throw away // the field you are typing into. function replaceDraft(next) { root.draft = next } // Run an action that reads or replaces the draft, but only after every // control has committed what is currently in it. // // A text field commits on `editingFinished` and a spin box parses its typed // text on focus loss, so a value typed and then acted on directly — type a // label, click Save — was never in the draft the action read, and the typed // text was lost with the delegate. Moving focus first makes each control // commit through its own normal path; focus goes back to the panel's key // scope so Escape keeps working. function withPendingEdits(action) { keyScope.forceActiveFocus() Qt.callLater(action) } // 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 { id: keyScope 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) { var i = row.index root.withPendingEdits(function () { root.updateRow(i, "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: { var i = row.index var next = !root.rowShowsSeconds(row.modelData) root.withPendingEdits(function () { root.updateRow(i, "showSeconds", next) }) } } Button { iconText: "󰅙" tooltipText: "Remove this clock" foreground: root.foreground accent: root.urgent onClicked: { var i = row.index root.withPendingEdits(function () { root.removeRow(i) }) } } } } 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.withPendingEdits(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.withPendingEdits(root.save) } } } } } } }