diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 836fa5e5..7aa23a4a 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -99,6 +99,7 @@ o.bind("SUPER + CTRL + A", "Audio", { panel = "omarchy.audio" }) o.bind("SUPER + CTRL + B", "Bluetooth", { panel = "omarchy.bluetooth" }) o.bind("SUPER + CTRL + D", "Display", { panel = "omarchy.monitor" }) o.bind("SUPER + CTRL + ALT + D", "Calendar", { panel = "omarchy.clock" }) +o.bind("SUPER + CTRL + ALT + E", "World clock", { panel = "omarchy.elsewhen" }) o.bind("SUPER + CTRL + W", "Network", { panel = "omarchy.network" }) o.bind("SUPER + CTRL + P", "Power", { panel = "omarchy.power" }) o.bind("SUPER + CTRL + T", "Activity", { tui = "btop" }) diff --git a/default/omarchy/shortcuts b/default/omarchy/shortcuts index 02aa5bbe..1bdeeeac 100644 --- a/default/omarchy/shortcuts +++ b/default/omarchy/shortcuts @@ -23,6 +23,7 @@ panel omarchy.audio panel omarchy.bluetooth panel omarchy.monitor panel omarchy.clock +panel omarchy.elsewhen panel omarchy.network panel omarchy.power audio raise diff --git a/manual/05-the-top-bar.md b/manual/05-the-top-bar.md index 09a02898..3f558d93 100644 --- a/manual/05-the-top-bar.md +++ b/manual/05-the-top-bar.md @@ -45,6 +45,7 @@ Clicking a bar icon opens a panel, which is a proper popup with sliders, lists, | `Super + Ctrl + D` | Display | | `Super + Ctrl + P` | Power | | `Super + Ctrl + Alt + D` | Calendar | +| `Super + Ctrl + Alt + E` | World clock | | `Super + Ctrl + 1-9` | Toggle the nth panel in the right section | The panels aren't read-outs. They're where you actually do the thing: diff --git a/manual/07-hotkeys.md b/manual/07-hotkeys.md index 72acdc8a..660b191c 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -68,6 +68,7 @@ You can see all the main keyboard bindings with `Super + K` (Tmux bindings with | `Super + Ctrl + D` | Display panel | | `Super + Ctrl + P` | Power panel | | `Super + Ctrl + Alt + D` | Calendar panel | +| `Super + Ctrl + Alt + E` | World clock panel | | `Super + Ctrl + 1-9` | Toggle bar panel by position | | `Super + Ctrl + S` | Share menu (via LocalSend) | | `Super + Ctrl + T` | Activity (btop) | diff --git a/shell/Ui/BarIconButton.qml b/shell/Ui/BarIconButton.qml index e2af8808..32214bb4 100644 --- a/shell/Ui/BarIconButton.qml +++ b/shell/Ui/BarIconButton.qml @@ -9,7 +9,8 @@ WidgetButton { property real slotSize: Style.bar.iconSlot property real opticalSize: Style.bar.iconCanvas property bool debugOpticalBounds: Quickshell.env("OMARCHY_DEBUG_BAR_ICONS") === "1" - readonly property real opticalCenterErrorX: glyph.visible ? glyph.paintedCenterX - opticalCanvas.width / 2 : 0 + // Measured against the slot, which the open-panel underline centers on. + readonly property real opticalCenterErrorX: glyph.visible ? opticalCanvas.x + glyph.paintedCenterX - root.width / 2 : 0 readonly property real glyphPaintedWidth: glyph.visible ? glyph.tightWidth : 0 readonly property real glyphBaselineY: glyph.visible ? glyph.baselineY : 0 readonly property int glyphFontSize: glyph.visible ? glyph.renderedFontSize : 0 @@ -22,7 +23,10 @@ WidgetButton { Item { id: opticalCanvas - anchors.centerIn: parent + // Placed exactly, not anchored: centerIn snaps an even canvas in an odd slot + // to a whole pixel, pulling every glyph off the open-panel underline. + x: (root.width - width) / 2 + y: (root.height - height) / 2 width: root.opticalSize height: root.opticalSize diff --git a/shell/Ui/OpticalGlyph.qml b/shell/Ui/OpticalGlyph.qml index a8881d49..201c2d01 100644 --- a/shell/Ui/OpticalGlyph.qml +++ b/shell/Ui/OpticalGlyph.qml @@ -28,8 +28,10 @@ Item { textFormat: Text.PlainText // Keep the shared line box and baseline intact. Correcting only the // horizontal painted bounds avoids per-glyph vertical drift. - anchors.centerIn: parent - anchors.horizontalCenterOffset: root.horizontalCorrection + // The correction is fractional, so x is set exactly: centerIn would snap it + // to a whole pixel and undo it. y still snaps, keeping the baseline crisp. + x: (root.width - width) / 2 + root.horizontalCorrection + y: Math.round((root.height - height) / 2) text: root.text color: root.color font.family: root.fontFamily diff --git a/shell/Ui/PanelKeyCatcher.qml b/shell/Ui/PanelKeyCatcher.qml index bac394e5..a49ebcdb 100644 --- a/shell/Ui/PanelKeyCatcher.qml +++ b/shell/Ui/PanelKeyCatcher.qml @@ -75,7 +75,7 @@ Item { if (event.key === Qt.Key_Space) { activateRequested(); event.accepted = true; return } - if (event.text === "x" || event.text === "X") { + if (event.key === Qt.Key_Delete || event.text === "x" || event.text === "X") { deleteRequested(); event.accepted = true; return } if (event.text && event.text.length === 1) { diff --git a/shell/plugins/panels/elsewhen/CityRow.qml b/shell/plugins/panels/elsewhen/CityRow.qml index b1343c72..42d4e028 100644 --- a/shell/plugins/panels/elsewhen/CityRow.qml +++ b/shell/plugins/panels/elsewhen/CityRow.qml @@ -45,7 +45,9 @@ Rectangle { implicitHeight: (pad - panel.capGap) + rowLabels.implicitHeight + stripGap + strip.trackHeight + pad radius: Style.cornerRadius // Opaque: knocked-aside rows pass over one another and over the globe. - color: Model.mix(Color.popups.background, foreground, rowHover.hovered ? phaseFill + 0.05 : phaseFill) + // The picked city lights up like a hovered one, so the arrow keys show where they are. + readonly property bool lit: rowHover.hovered || (panel.focusIndex === index && !panel.addSelected) + color: Model.mix(Color.popups.background, foreground, lit ? phaseFill + 0.05 : phaseFill) // Transforms leave the Column's layout alone: the knock that clears the // globe's way, then the drag offset. @@ -288,7 +290,8 @@ Rectangle { fontFamily: row.fontFamily fontSize: Style.font.bodySmall enabled: row.removable - opacity: row.removable && rowHover.hovered ? 1 : 0 + // Shown on the keyboard's pick too, so Delete's target is plain. + opacity: row.removable && row.lit ? 1 : 0 visible: opacity > 0 Behavior on opacity { NumberAnimation { duration: 120 } } onClicked: row.panel.removeCityAt(row.index) diff --git a/shell/plugins/panels/elsewhen/CitySearch.qml b/shell/plugins/panels/elsewhen/CitySearch.qml index 5400c035..e91cae2c 100644 --- a/shell/plugins/panels/elsewhen/CitySearch.qml +++ b/shell/plugins/panels/elsewhen/CitySearch.qml @@ -27,6 +27,8 @@ Column { // False when the host shows CityMatches elsewhere. property bool inlineResults: true + // The host's keyboard cursor is on the button. + property bool hasCursor: false property bool active: false readonly property string query: field.text @@ -74,6 +76,7 @@ Column { foreground: search.foreground fontFamily: search.fontFamily bordered: true + hasCursor: search.hasCursor onClicked: search.start() } diff --git a/shell/plugins/panels/elsewhen/Model.js b/shell/plugins/panels/elsewhen/Model.js index 16ed9a85..4ecbd0ce 100644 --- a/shell/plugins/panels/elsewhen/Model.js +++ b/shell/plugins/panels/elsewhen/Model.js @@ -673,9 +673,13 @@ function formatScrubDelta(minutes) { if (m === 0) return "" var sign = m > 0 ? "+" : "-" var a = Math.abs(m) - if (a < 60) return sign + a + "m" - var h = Math.floor(a / 60), rem = a % 60 - return sign + h + "h" + (rem ? " " + rem + "m" : "") + // The arrow keys can run past a day, where hours alone stop reading well. + var d = Math.floor(a / DAY_MINUTES), h = Math.floor(a % DAY_MINUTES / 60), rem = a % 60 + var parts = [] + if (d) parts.push(d + "d") + if (h) parts.push(h + "h") + if (rem) parts.push(rem + "m") + return sign + parts.join(" ") } // ---- the strip's sunrise arrows diff --git a/shell/plugins/panels/elsewhen/Panel.qml b/shell/plugins/panels/elsewhen/Panel.qml index 8c01bb71..5c635e86 100644 --- a/shell/plugins/panels/elsewhen/Panel.qml +++ b/shell/plugins/panels/elsewhen/Panel.qml @@ -104,6 +104,7 @@ Panel { property int zoomEasing: Easing.OutQuart function setGlobeMode(on, slow) { + if (on) addSelected = false zoomDuration = (on ? 800 : 500) * (slow === true ? slowMotionFactor : 1) zoomEasing = on ? Easing.OutQuart : Easing.InOutCubic globeMode = on @@ -200,6 +201,13 @@ Panel { function endScrub() { scrubHold.restart() } + // Held until Escape or close: a key press has no release to time from. Unlike a + // drag, which stays within the strip's day, the keys can run on into other days. + function shiftHour(step) { + scrubHold.stop() + scrubMinutes = Math.round(scrubMinutes) + step * 60 + } + // ---- the moon // Shift-click a moon marker to walk the phase through a lunation. property bool moonShowing: false @@ -265,11 +273,47 @@ Panel { readonly property real focusLon: focusKnown ? focusPlace.lon : 0 function focusOn(index) { + addSelected = false var from = hero.spin focusKey = index >= 0 && index < zones.length ? Model.factsKey(zones[index]) : "" if (focusKnown) hero.turn(from, focusLon) } + // Past the last city, the list's cursor rests on "Add a city"; the globe stays put. + property bool addSelected: false + + // Up and down walk home, each city and then "Add a city", wrapping round like the + // search list. The globe has no add row, so there the walk skips it. + function moveFocus(step) { + var at = addSelected ? zones.length : focusIndex + var next = Model.moveSelection(at + 1, step, zones.length + (globeMode ? 1 : 2)) - 1 + if (next === zones.length) { + addSelected = true + scrollToItem(citySearch) + return + } + focusOn(next) + if (globeMode) showFocusOnGlobe() + else scrollToItem(next >= 0 ? cityRows.itemAt(next) : null) + } + + // The picked city goes, as with its × button, and the cursor lands on the city + // that takes its place. The last city stays, so there is always one clock. + function deleteFocused() { + if (globeMode || addSelected || focusIndex < 0 || zones.length < 2) return + var at = focusIndex + removeCityAt(at) + focusOn(Math.min(at, zones.length - 1)) + } + + function scrollToItem(item) { + if (!item) { scroller.scrollToTop(); return } + var top = item.mapToItem(content, 0, 0).y + if (top < scroller.contentY) scroller.scrollTo(top) + else if (top + item.height > scroller.contentY + scroller.height) + scroller.scrollTo(Math.min(scroller.maxScroll, top + item.height - scroller.height)) + } + // Surrogate pairs rather than literal glyphs, which re-encoding can break. function weatherGlyph(zone) { var f = facts[Model.factsKey(zone)] @@ -460,6 +504,7 @@ Panel { onOpenedChanged: { if (opened) { focusKey = "" + addSelected = false tick(); refresh(); refreshFacts(); loadCatalog() startOpeningSpin() } else { @@ -669,17 +714,27 @@ Panel { id: keyCatcher anchors.fill: parent blocked: root.adding - // Escape unwinds one layer: search (its own field), globe, then panel. + // Escape unwinds one layer: search (its own field), a shifted time, globe, then panel. onCloseRequested: { - if (root.globeMode) root.setGlobeMode(false, false) + if (root.scrubMinutes !== 0) { scrubHold.stop(); root.scrubMinutes = 0 } + else if (root.globeMode) root.setGlobeMode(false, false) else root.close() } - // Space toggles the globe. Return also arrives as an activate, which it skips. + onDeleteRequested: root.deleteFocused() + // Up and down pick a city; left and right move the clocks an hour. + onMoveRequested: function(dx, dy) { + if (dy !== 0) root.moveFocus(dy) + else root.shiftHour(dx) + } + // Return or Space on "Add a city" opens the search. Otherwise Space toggles + // the globe, and Return, which also arrives as an activate, does nothing. property bool returnHandled: false onReturnRequested: returnHandled = true onActivateRequested: { - if (returnHandled) { returnHandled = false; return } - if (root.globeEnabled) root.setGlobeMode(!root.globeMode, false) + var fromReturn = returnHandled + returnHandled = false + if (root.addSelected) root.startAdding() + else if (!fromReturn && root.globeEnabled) root.setGlobeMode(!root.globeMode, false) } onTabRequested: function(direction) { root.switchPanel(direction) } // "+" searches in either view; "j" jumps on the globe, "a" adds on the list. @@ -792,6 +847,7 @@ Panel { // The zone list, not clockRows: that ticks, and would // rebuild every delegate mid-drag. Repeater { + id: cityRows model: root.zones CityRow { panel: root } @@ -805,6 +861,7 @@ Panel { options: root.zoneOptions loading: root.zoneCatalogText === "" offsetLabel: function(zoneId) { return root.utcLabelFor(zoneId) } + hasCursor: root.addSelected foreground: root.foreground dim: root.dim fainter: root.fainter diff --git a/test/shell.d/bar-icon-geometry-test.sh b/test/shell.d/bar-icon-geometry-test.sh index 6f54692c..626bf405 100644 --- a/test/shell.d/bar-icon-geometry-test.sh +++ b/test/shell.d/bar-icon-geometry-test.sh @@ -40,8 +40,9 @@ ShellRoot { fail(name + " optical canvas is " + icon.opticalSize) return false } - if (Math.abs(icon.opticalCenterErrorX) > 0.5) { - fail(name + " painted bounds are over half a pixel off center by " + icon.opticalCenterErrorX) + // The open-panel underline centers on the slot, so the glyph must too. + if (Math.abs(icon.opticalCenterErrorX) > 0.05) { + fail(name + " painted bounds are off the slot's center by " + icon.opticalCenterErrorX) return false } if (icon.glyphFontSize !== Style.bar.iconFont) { @@ -57,6 +58,7 @@ ShellRoot { if (!checkIcon(audio, "audio")) return if (!checkIcon(monitor, "monitor")) return if (!checkIcon(power, "power")) return + if (!checkIcon(elsewhen, "elsewhen")) return var baseline = bluetooth.glyphBaselineY if (network.glyphBaselineY !== baseline || audio.glyphBaselineY !== baseline || monitor.glyphBaselineY !== baseline || power.glyphBaselineY !== baseline) { @@ -79,7 +81,7 @@ ShellRoot { fail("indicator does not use the secondary icon scale") return } - if (Math.abs(verticalIndicator.opticalCenterErrorX) > 0.5) { + if (Math.abs(verticalIndicator.opticalCenterErrorX) > 0.05) { fail("vertical indicator is not optically centered") return } @@ -134,6 +136,7 @@ ShellRoot { BarIconButton { id: audio; bar: testBar; text: "󰖁" } BarIconButton { id: monitor; bar: testBar; text: "󰍹" } BarIconButton { id: power; bar: testBar; text: "󰁹" } + BarIconButton { id: elsewhen; bar: testBar; text: "󰇧" } BarIconButton { id: vector bar: testBar diff --git a/test/shell.d/elsewhen-model-test.sh b/test/shell.d/elsewhen-model-test.sh index 00dc2955..68795f88 100644 --- a/test/shell.d/elsewhen-model-test.sh +++ b/test/shell.d/elsewhen-model-test.sh @@ -60,6 +60,7 @@ assertEqual(Math.round(M.scrubDeltaMinutes(-1, 720)), 720, 'elsewhen resolves a assertEqual(Math.round(M.scrubDeltaMinutes(1, 720)), 720, 'elsewhen resolves a half-day scrub forward from the other edge') assertEqual(Math.round(M.scrubDeltaMinutes(600 / 1440, 600)), 0, 'elsewhen scrub at the current time is zero') assertDeepEqual([M.formatScrubDelta(0), M.formatScrubDelta(45), M.formatScrubDelta(-90), M.formatScrubDelta(180)], ['', '+45m', '-1h 30m', '+3h'], 'elsewhen formats scrub deltas') +assertDeepEqual([M.formatScrubDelta(1440), M.formatScrubDelta(-2220), M.formatScrubDelta(1500)], ['+1d', '-1d 13h', '+1d 1h'], 'elsewhen formats scrub deltas past a day in days') assertDeepEqual([M.formatMinuteOfDay(0, false), M.formatMinuteOfDay(13 * 60 + 5, false), M.formatMinuteOfDay(13 * 60, true)], ['12:00 AM', '1:05 PM', '13:00'], 'elsewhen formats minutes of the day') assertEqual(M.formatMinuteOfDay(419.81, false), '7:00 AM', 'elsewhen rolls the hour when rounding seconds') @@ -148,6 +149,7 @@ assertEqual(M.moveSelection(4, 1, 5), 0, 'elsewhen wraps the selection past the assertEqual(M.moveSelection(0, -1, 5), 4, 'elsewhen wraps the selection past the start') assertEqual(M.moveSelection(3, 1, 0), 0, 'elsewhen resets the selection in an empty list') + const home = ['Copenhagen', 'Europe/Copenhagen', 55.68, 12.57, 0] const merged = M.mergeCities( home,