mirror of
https://github.com/omacom/omarchy.git
synced 2026-09-28 06:13:13 +08:00
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:
co-authored by
Claude Opus 5.5
parent
82979b9a18
commit
773e19e145
@@ -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" })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user