Files
omarchy/shell/plugins/panels/elsewhen/CityRow.qml
T
David Heinemeier HanssonandClaude Opus 5.5 e7218e36f2 Move Elsewhen into Omarchy as omarchy.elsewhen (#13429)
* Move Elsewhen into Omarchy as omarchy.elsewhen

The world clock ships in the shell tree instead of its own package. A
migration renames existing bar entries with their settings and removes
the retired package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove Elsewhen's shelved Earth row, overlap band and sky tint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tidy Elsewhen's models, data helper and docs, and drop currency

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restructure Elsewhen's panel and globe on the shared shell components

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show Elsewhen's bar globe upright

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the shared city search for Elsewhen's globe jump bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep legacy Elsewhen entries single and retry offline geocodes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch Elsewhen's weather with curl instead of a Python helper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:15:00 +02:00

367 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.
color: Model.mix(Color.popups.background, foreground, rowHover.hovered ? 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
opacity: row.removable && rowHover.hovered ? 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() }
}
}