Drive Elsewhen from the keyboard and summon it with Super+Ctrl+Alt+E (#13445)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-09-27 22:05:41 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 82979b9a18
commit 773e19e145
13 changed files with 100 additions and 18 deletions
+1
View File
@@ -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" })
+1
View File
@@ -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
+1
View File
@@ -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:
+1
View File
@@ -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) |
+6 -2
View File
@@ -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
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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) {
+5 -2
View File
@@ -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)
@@ -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()
}
+7 -3
View File
@@ -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
+62 -5
View File
@@ -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
+6 -3
View File
@@ -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
+2
View File
@@ -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,