Files
omarchy/shell/plugins/panels/elsewhen/CityRow.qml
T
David Heinemeier HanssonandClaude Opus 5.5 773e19e145 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>
2026-09-27 22:05:41 +02:00

370 lines
11 KiB
QML

import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Ui
import "Model.js" as Model
import "Greetings.js" as Greet
// One city: name, weather, date (or a local greeting on hover) and the time,
// over its daylight strip. Drag the body to reorder, click it to turn the
// globe there. Every toggle on the row flips the setting for the whole list.
Rectangle {
id: row
required property var modelData
required property int index
// The owning Panel: the row reads its clock, facts and drag state.
property var panel
readonly property color foreground: panel.foreground
readonly property color dim: panel.dim
readonly property color fainter: panel.fainter
readonly property string fontFamily: panel.fontFamily
readonly property var rowData: panel.clockRows[index]
|| ({ ready: false, label: modelData.label, id: modelData.id })
readonly property bool ready: rowData.ready
readonly property var greeting: Greet.greeting(rowData.id, ready ? rowData.hour : 0)
readonly property bool removable: panel.clockRows.length > 1
readonly property bool dragged: panel.dragIndex === index
// Lightest at midday, darkest at night, so the list dims into the small hours.
readonly property real phaseFill: {
var p = ready ? rowData.phase : "day"
if (p === "day") return 0.10
if (p === "night") return 0.035
return 0.07
}
readonly property int pad: Style.space(15)
readonly property int stripGap: Style.space(9)
width: parent.width
// The top pad is measured to the cap height, not the line box, so the row
// looks as tall above the name as below the strip.
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.
// 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.
transform: [
Translate {
x: Model.knockX(row.index, row.panel.zoom, Style.space(95))
y: Model.knockY(row.index, row.panel.zoom, row.panel.knockFall)
},
Rotation {
origin.x: row.width / 2
origin.y: row.height / 2
angle: Model.knockTilt(row.index, row.panel.zoom)
},
Scale {
origin.x: row.width / 2
origin.y: row.height / 2
xScale: Model.knockShrink(row.index, row.panel.zoom)
yScale: Model.knockShrink(row.index, row.panel.zoom)
},
Translate {
y: Model.rowShift(row.index, row.panel.dragIndex, row.panel.dragTarget,
row.panel.dragOffset, row.panel.rowPitch)
// The dragged row follows the pointer one-to-one; the others ease aside.
Behavior on y {
enabled: !row.dragged
NumberAnimation { duration: 130; easing.type: Easing.OutCubic }
}
}
]
z: dragged ? 2 : 0
opacity: dragged ? 0.9 : 1
Behavior on opacity { NumberAnimation { duration: 120 } }
HoverHandler { id: rowHover }
// The row body is the reorder grab; later siblings keep their own taps.
MouseArea {
id: grab
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: strip.top
preventStealing: true
cursorShape: row.dragged ? Qt.ClosedHandCursor : Qt.OpenHandCursor
property real pressY: 0
property bool armed: false
property int chipAtPress: Model.NO_CHIP
// Measured in the Column, which does not move; the row's own frame slides
// with the drag and would feed the offset back into itself.
function pointerY(mouse) { return grab.mapToItem(row.parent, 0, mouse.y).y }
function finish() {
armed = false
strip.dismissUnlessChanged(chipAtPress)
}
onPressed: function(mouse) {
pressY = pointerY(mouse)
armed = false
chipAtPress = strip.shownChip
}
onPositionChanged: function(mouse) {
var dy = pointerY(mouse) - pressY
// A few pixels of slack, so a click is never a reorder.
if (!armed) {
if (Math.abs(dy) < Style.space(4)) return
armed = true
row.panel.beginRowDrag(row.index, row.height + row.parent.spacing)
}
row.panel.moveRowDrag(dy)
}
onReleased: {
if (armed) row.panel.releaseRowDrag()
else row.panel.focusOn(row.index)
finish()
}
onCanceled: {
row.panel.cancelRowDrag()
finish()
}
}
Column {
id: rowLabels
anchors.left: parent.left
anchors.leftMargin: Style.spacing.rowPaddingX
anchors.right: timeBlock.left
anchors.rightMargin: Style.spacing.xl
anchors.top: parent.top
anchors.topMargin: row.pad - row.panel.capGap
spacing: Style.space(2)
// A layout so the name is what gives way when the line is tight.
RowLayout {
width: parent.width
spacing: Style.space(7)
// Caps the name at its unelided width; the Text's own implicitWidth
// shrinks as it elides, which would make the cap circular.
TextMetrics {
id: nameMetrics
font.family: row.fontFamily
font.pixelSize: Style.font.subtitle
font.weight: Font.DemiBold
text: row.rowData.label
}
Text {
Layout.fillWidth: true
Layout.maximumWidth: Math.ceil(nameMetrics.advanceWidth) + 2
Layout.minimumWidth: 0
Layout.alignment: Qt.AlignBaseline
textFormat: Text.PlainText
text: row.rowData.label
color: row.foreground
font.family: row.fontFamily
font.pixelSize: Style.font.subtitle
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Caption {
Layout.alignment: Qt.AlignBaseline
text: Model.tempLabel(row.panel.facts[Model.factsKey(row.rowData)], row.panel.units)
visible: text !== ""
color: tempHover.hovered ? row.foreground : row.dim
HoverHandler {
id: tempHover
cursorShape: Qt.PointingHandCursor
}
TapHandler { onTapped: { row.panel.toggleUnits(); strip.dismissChips() } }
}
Caption {
Layout.alignment: Qt.AlignBaseline
text: row.panel.weatherGlyph(row.rowData)
visible: text !== ""
font.pixelSize: Style.font.bodySmall
}
}
// The date, and the greeting in its place while hovered. Stacked in one
// slot so the row never changes size under the pointer.
Item {
width: parent.width
implicitHeight: dateLine.implicitHeight
// No greeting before the probe lands: a wrong one is worse than none.
readonly property bool greeting: row.ready && rowHover.hovered
Row {
id: dateLine
spacing: Style.spacing.sm
opacity: parent.greeting ? 0 : 1
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 110 } }
Caption {
text: row.ready ? row.rowData.date : "\u2026"
color: row.ready && row.rowData.dayLabel !== "" ? row.dim : row.fainter
}
Caption {
text: "\u00b7"
visible: row.ready && row.rowData.dayLabel !== ""
color: row.fainter
}
Caption {
text: row.ready ? row.rowData.dayLabel : ""
visible: text !== ""
}
}
Row {
spacing: Style.space(5)
opacity: parent.greeting ? 1 : 0
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 110 } }
// Non-Latin scripts fall back per character through fontconfig.
Caption {
text: row.greeting.text
color: row.foreground
}
// A pronunciation, not a translation; absent for Latin scripts.
Caption {
text: row.greeting.roman
visible: text !== ""
color: row.fainter
}
}
}
}
DaylightStrip {
id: strip
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Style.spacing.rowPaddingX
anchors.rightMargin: Style.spacing.rowPaddingX
anchors.verticalCenter: parent.bottom
anchors.verticalCenterOffset: -(row.pad + trackHeight / 2)
ready: row.ready
offsetMinutes: row.ready ? row.rowData.offsetMinutes : 0
progress: row.ready ? row.rowData.progress : 0
fallbackLit: row.ready && row.rowData.lit
place: row.panel.facts[Model.factsKey(row.modelData)] || null
nowMs: row.panel.effectiveMs
hovered: rowHover.hovered
hour24: row.panel.hour24
moonPhase: row.panel.moonPhase
foreground: row.foreground
fainter: row.fainter
daylightMarker: row.panel.daylightMarker
fontFamily: row.fontFamily
onScrubStarted: function(fraction) { row.panel.beginScrub(row.rowData, fraction) }
onScrubMoved: function(fraction) { row.panel.scrubTo(row.rowData, fraction) }
onScrubEnded: row.panel.endScrub()
onMoonShowRequested: row.panel.startMoonShow()
}
// The last city stays; removing it would leave no way back.
PanelActionButton {
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.space(1)
iconText: "\u00d7"
tooltipText: "Remove"
foreground: row.fainter
hoverColor: row.foreground
fontFamily: row.fontFamily
fontSize: Style.font.bodySmall
enabled: row.removable
// 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)
}
Column {
id: timeBlock
anchors.right: parent.right
anchors.rightMargin: Style.spacing.rowPaddingX
anchors.verticalCenter: rowLabels.verticalCenter
spacing: Style.space(1)
// The hour is already the brightest thing on the row, so hover lifts the meridiem.
LinkRow {
id: timeLine
anchors.right: parent.right
spacing: Style.spacing.xs
onClicked: { row.panel.toggleHour24(); strip.dismissChips() }
Text {
id: bigTime
textFormat: Text.PlainText
text: row.ready ? row.rowData.time : "--:--"
color: row.foreground
font.family: row.fontFamily
font.pixelSize: Style.font.heading
font.weight: Font.DemiBold
}
Caption {
anchors.baseline: bigTime.baseline
text: row.rowData.meridiem || ""
visible: text !== ""
color: timeLine.hovered ? row.foreground : row.dim
}
}
LinkRow {
id: offsetLine
anchors.right: parent.right
spacing: Style.space(5)
visible: row.ready
onClicked: { row.panel.toggleOffsetMode(); strip.dismissChips() }
Caption {
text: row.ready ? row.rowData.abbr : ""
color: offsetLine.hovered ? row.dim : row.fainter
}
Caption {
text: row.panel.offsetTextFor(row.rowData)
visible: text !== ""
color: offsetLine.hovered ? row.foreground : row.fainter
}
}
}
component Caption: Text {
textFormat: Text.PlainText
color: row.dim
font.family: row.fontFamily
font.pixelSize: Style.font.caption
}
component LinkRow: Row {
id: link
signal clicked()
readonly property bool hovered: linkHover.hovered
HoverHandler {
id: linkHover
cursorShape: Qt.PointingHandCursor
}
TapHandler { onTapped: link.clicked() }
}
}