From 773e19e145be29cf452f394c4a609a3a51e1ac5b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 27 Sep 2026 22:05:41 +0200 Subject: [PATCH] Drive Elsewhen from the keyboard and summon it with Super+Ctrl+Alt+E (#13445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Navigate Elsewhen from the keyboard and summon it with Super+Ctrl+Alt+E Up and down walk home and the cities, lighting the picked row and turning the globe to it. Left and right move the clocks an hour, held until Escape or close. Escape now clears a shifted time before leaving the globe. Co-Authored-By: Claude Opus 5.5 * Reach Add a city from the keyboard and shift time past a day Down past the last city selects Add a city, where Return or Space opens the search. The arrow keys no longer stop at twelve hours, and shifts past a day read in days. Co-Authored-By: Claude Opus 5.5 * Delete the picked Elsewhen city from the keyboard Delete (or x) removes the picked city and moves the cursor to the one that takes its place. The picked row shows its × so the target is plain. Delete now reaches every panel's deleteRequested, as it already does in the clipboard and the menu. Co-Authored-By: Claude Opus 5.5 * Center bar glyphs on the open-panel underline The 16px icon canvas in the 27px slot was snapped from 5.5 to 6, and the glyph's fractional centering correction was snapped too, so glyphs sat up to 0.9px right of the underline, which centers on the slot. Both are now placed exactly, and the geometry test measures against the slot to within 0.05px. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- default/hypr/bindings/utilities.lua | 1 + default/omarchy/shortcuts | 1 + manual/05-the-top-bar.md | 1 + manual/07-hotkeys.md | 1 + shell/Ui/BarIconButton.qml | 8 ++- shell/Ui/OpticalGlyph.qml | 6 +- shell/Ui/PanelKeyCatcher.qml | 2 +- shell/plugins/panels/elsewhen/CityRow.qml | 7 +- shell/plugins/panels/elsewhen/CitySearch.qml | 3 + shell/plugins/panels/elsewhen/Model.js | 10 ++- shell/plugins/panels/elsewhen/Panel.qml | 67 ++++++++++++++++++-- test/shell.d/bar-icon-geometry-test.sh | 9 ++- test/shell.d/elsewhen-model-test.sh | 2 + 13 files changed, 100 insertions(+), 18 deletions(-) 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,