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 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++) copy.push({ label: String(clocks[i].label || ""), timezone: String(clocks[i].timezone || "") }) 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 } // 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 } function updateRow(index, key, value) { var next = root.draft.slice() if (!next[index]) return var row = { label: next[index].label, timezone: next[index].timezone } 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, "") out.push({ label: label || tz.split("/").pop().replace(/_/g, " "), timezone: tz }) } 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: cfg ? cfg.showSeconds === true : false, barSize: cfg ? cfg.barSize : 0 }) 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(74) 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 { 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 } 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() } } } } } } }