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
280 lines
9.4 KiB
QML
280 lines
9.4 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import qs.Commons
|
|
import qs.Ui
|
|
import "ClockModel.js" as Model
|
|
|
|
// A second bar along the bottom edge of every screen, carrying world clocks
|
|
// laid out as a single timeline: furthest behind local time at the far left,
|
|
// furthest ahead at the far right, local time dead centre.
|
|
//
|
|
// It is a `panel` plugin rather than a set of bar widgets because it is its
|
|
// own layer-shell surface — the built-in bar owns the top edge and only one
|
|
// bar plugin is active at a time, so sharing it was never an option. Being a
|
|
// separate surface also means it reserves its own space and survives
|
|
// `omarchy refresh shell` untouched.
|
|
Item {
|
|
id: root
|
|
|
|
// Injected by the shell host when the plugin is mounted.
|
|
property string omarchyPath: ""
|
|
property var shell: null
|
|
property var manifest: null
|
|
|
|
readonly property var config: configFile.config
|
|
readonly property var bar: Model.buildBar(config, zones.offsets, root.nowMs, zones.localOffset)
|
|
|
|
property double nowMs: Date.now()
|
|
property int lastProbedHour: -1
|
|
|
|
// Follows the theme's bar height unless the config pins one, so the two bars
|
|
// stay the same weight when the theme or font size changes.
|
|
readonly property int barSize: config && config.barSize > 0 ? config.barSize : Style.bar.sizeHorizontal
|
|
|
|
// Everything drawn on the bar is sized against the theme's own bar height,
|
|
// so a taller bar carries proportionally larger type and gaps rather than
|
|
// the theme's 26px-sized text marooned in the middle of it. At the default
|
|
// height this is exactly 1, which leaves the theme's own tokens untouched.
|
|
readonly property real barScale: Style.bar.sizeHorizontal > 0
|
|
? barSize / Style.bar.sizeHorizontal
|
|
: 1
|
|
|
|
function scaled(px) { return Math.max(1, Math.round(px * root.barScale)) }
|
|
|
|
readonly property int timeFontSize: scaled(Style.font.body)
|
|
readonly property int labelFontSize: scaled(Style.font.caption)
|
|
|
|
readonly property color background: Color.bar.background
|
|
readonly property color foreground: Color.bar.text
|
|
readonly property color muted: Color.muted
|
|
readonly property color accent: Color.accent
|
|
readonly property color urgent: Color.urgent
|
|
|
|
readonly property int edgePadding: scaled(Style.space(12))
|
|
readonly property int clockGap: scaled(Style.space(16))
|
|
readonly property int labelGap: scaled(Style.space(6))
|
|
|
|
Config { id: configFile }
|
|
|
|
Zones {
|
|
id: zones
|
|
zones: Model.zonesToProbe(root.config)
|
|
}
|
|
|
|
// Seconds are opt-in per clock, so the tick only drops to per-second when
|
|
// something on the bar is actually displaying them — otherwise this wakes
|
|
// the whole shell sixty times as often to redraw an identical minute.
|
|
SystemClock {
|
|
id: clock
|
|
precision: Model.anyShowsSeconds(root.config) ? SystemClock.Seconds : SystemClock.Minutes
|
|
onDateChanged: {
|
|
root.nowMs = date.getTime()
|
|
// Offsets only move at a transition, and transitions land on the hour.
|
|
var hour = date.getHours()
|
|
if (hour !== root.lastProbedHour) {
|
|
root.lastProbedHour = hour
|
|
zones.probe()
|
|
}
|
|
}
|
|
}
|
|
|
|
function openEditor() { editorLoader.active = true; if (editorLoader.item) editorLoader.item.open() }
|
|
function closeEditor() { if (editorLoader.item) editorLoader.item.close() }
|
|
readonly property bool editorOpen: editorLoader.item ? editorLoader.item.opened : false
|
|
|
|
Loader {
|
|
id: editorLoader
|
|
active: false
|
|
asynchronous: true
|
|
source: Qt.resolvedUrl("Editor.qml")
|
|
onLoaded: {
|
|
item.configFile = configFile
|
|
item.zones = zones
|
|
item.open()
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "nerdnation.clockbar"
|
|
|
|
function edit(): void { root.openEditor() }
|
|
function open(): void { root.openEditor() }
|
|
function close(): void { root.closeEditor() }
|
|
function toggle(): void { root.editorOpen ? root.closeEditor() : root.openEditor() }
|
|
function refresh(): void { zones.probe() }
|
|
function reload(): void { configFile.file.reload() }
|
|
}
|
|
|
|
// ------------------------------------------------------------- one clock
|
|
|
|
component Clock: Row {
|
|
id: entry
|
|
|
|
required property var clock
|
|
|
|
readonly property bool dimmed: root.config && root.config.dimOutsideWorkingHours
|
|
&& entry.clock.known && !entry.clock.working
|
|
readonly property color tint: !entry.clock.known ? root.urgent
|
|
: (entry.dimmed ? root.muted : root.foreground)
|
|
|
|
spacing: root.labelGap
|
|
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: entry.clock.label
|
|
color: entry.tint
|
|
// The label is a fixed caption next to a changing number, so it sits a
|
|
// step down the scale — present for identification, not competing with
|
|
// the time for the glance.
|
|
font.family: Style.font.family
|
|
font.pixelSize: root.labelFontSize
|
|
// Held back from the time it captions, but only once: a dimmed clock is
|
|
// already carrying the muted tint, and compounding the two put its label
|
|
// under the legibility floor.
|
|
opacity: entry.dimmed || !entry.clock.known ? 1.0 : 0.75
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: entry.clock.time
|
|
color: entry.tint
|
|
font.family: Style.font.family
|
|
font.pixelSize: root.timeFontSize
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// Collapsed rather than blanked, so a clock with no marker does not
|
|
// carry an empty slot plus its Row spacing. The group re-flows when a
|
|
// marker appears at midnight, which is the cheaper trade against dead
|
|
// width on every clock, every day.
|
|
visible: entry.clock.marker !== ""
|
|
text: entry.clock.marker
|
|
color: entry.tint
|
|
font.family: Style.font.family
|
|
font.pixelSize: root.labelFontSize
|
|
opacity: 0.7
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------ the surface
|
|
|
|
component ClockPanel: PanelWindow {
|
|
id: panel
|
|
|
|
color: root.background
|
|
// Bottom edge, full width, reserving its own height so Hyprland tiles
|
|
// windows above it exactly as it does for the top bar.
|
|
anchors { bottom: true; left: true; right: true }
|
|
implicitHeight: root.barSize
|
|
exclusionMode: ExclusionMode.Auto
|
|
surfaceFormat.opaque: false
|
|
WlrLayershell.namespace: "nerdnation-clockbar"
|
|
WlrLayershell.layer: WlrLayer.Top
|
|
|
|
ScreenMoveRemap {
|
|
id: remapGuard
|
|
window: panel
|
|
}
|
|
|
|
visible: !remapGuard.remapping
|
|
|
|
MouseArea {
|
|
id: surfaceHover
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
onClicked: root.openEditor()
|
|
}
|
|
|
|
// A hairline along the top edge separates the bar from the window below
|
|
// it, which otherwise meets it as one flat field of colour. It doubles as
|
|
// the config-error signal: full-width chrome is the one thing on the bar
|
|
// that cannot be overlapped by a long list of clocks.
|
|
Rectangle {
|
|
anchors { top: parent.top; left: parent.left; right: parent.right }
|
|
height: configFile.parseError !== "" ? root.scaled(2) : Math.max(1, root.scaled(1))
|
|
color: configFile.parseError !== "" ? root.urgent : root.foreground
|
|
opacity: configFile.parseError !== "" ? 0.9 : 0.12
|
|
}
|
|
|
|
Row {
|
|
id: leftGroup
|
|
anchors { left: parent.left; leftMargin: root.edgePadding; verticalCenter: parent.verticalCenter }
|
|
spacing: root.clockGap
|
|
|
|
Repeater {
|
|
model: root.bar.left
|
|
delegate: Clock {
|
|
required property var modelData
|
|
clock: modelData
|
|
}
|
|
}
|
|
}
|
|
|
|
// Local time is centred on the screen rather than between its neighbours,
|
|
// so it holds still while the groups either side change width.
|
|
Row {
|
|
id: centerGroup
|
|
anchors { horizontalCenter: parent.horizontalCenter; verticalCenter: parent.verticalCenter }
|
|
spacing: root.labelGap
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.bar.center.time
|
|
color: root.accent
|
|
font.family: Style.font.family
|
|
font.pixelSize: root.timeFontSize
|
|
font.bold: true
|
|
}
|
|
|
|
// Only while clockbar.json will not parse. The clocks either side are
|
|
// the last good config still ticking, which is worth keeping on screen —
|
|
// but not worth passing off as current.
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: configFile.parseError !== ""
|
|
text: "config error"
|
|
color: root.urgent
|
|
font.family: Style.font.family
|
|
font.pixelSize: root.labelFontSize
|
|
}
|
|
}
|
|
|
|
Row {
|
|
id: rightGroup
|
|
anchors { right: parent.right; rightMargin: root.edgePadding; verticalCenter: parent.verticalCenter }
|
|
spacing: root.clockGap
|
|
|
|
Repeater {
|
|
model: root.bar.right
|
|
delegate: Clock {
|
|
required property var modelData
|
|
clock: modelData
|
|
}
|
|
}
|
|
}
|
|
|
|
// A broken config file is signalled on the hairline (see above) rather
|
|
// than in a text label. The earlier label was anchored to the right edge
|
|
// with the same margin as the right-hand clock group, so it rendered on
|
|
// top of those clocks — and any fixed position on a bar whose contents
|
|
// grow with the config has that same collision waiting for it.
|
|
}
|
|
|
|
Variants {
|
|
model: Quickshell.screens
|
|
|
|
delegate: Component {
|
|
ClockPanel {
|
|
required property var modelData
|
|
screen: modelData
|
|
}
|
|
}
|
|
}
|
|
}
|