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>
This commit is contained in:
1 parent
5c7cef1e78
commit
e7218e36f2
37 files changed
+6780
-155
No files matched your search
@@ -0,0 +1,888 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
import "GlobeModel.js" as Solar
|
||||
|
||||
// A bar button that opens a panel of clocks, one row per city, and zooms into
|
||||
// a globe. QML has no Intl, so a `date` probe supplies each zone's offset and
|
||||
// the rows tick locally against it; the probe re-runs on open and every five
|
||||
// minutes to catch DST.
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "omarchy.elsewhen"
|
||||
ipcTarget: "omarchy.elsewhen"
|
||||
manageIpc: false
|
||||
|
||||
|
||||
// ---- settings
|
||||
readonly property var zones: Model.parseZones(setting("zones", ""))
|
||||
readonly property var zoneIds: zones.map(function(z) { return z.id })
|
||||
// Never written: the first probe seeds the list around the local zone.
|
||||
readonly property bool needsSeed: String(setting("zones", "")).trim() === ""
|
||||
readonly property var probeIds: needsSeed ? zoneIds.concat(Model.seedCandidateZones()) : zoneIds
|
||||
|
||||
readonly property bool autoHour24: Model.usesTwentyFourHour(Qt.locale().timeFormat(Locale.ShortFormat))
|
||||
readonly property bool hour24: Model.resolveHour24(setting("hour24", ""), autoHour24)
|
||||
// "home" shows the distance from here ("+2h"), "utc" the zone itself ("UTC-5").
|
||||
readonly property string offsetMode: setting("offsetMode", "home") === "utc" ? "utc" : "home"
|
||||
// Only the US measurement system means Fahrenheit.
|
||||
readonly property string autoUnits: Qt.locale().measurementSystem === Locale.ImperialUSSystem ? "F" : "C"
|
||||
readonly property string units: Model.resolveUnits(setting("units", ""), autoUnits)
|
||||
readonly property bool globeEnabled: setting("globeEnabled", true) !== false
|
||||
// Draw the globe with less in it while it moves.
|
||||
readonly property bool smoothMotion: setting("smoothMotion", true) !== false
|
||||
|
||||
function toggleHour24() { persistSettings({ hour24: !hour24 }) }
|
||||
function toggleOffsetMode() { persistSettings({ offsetMode: offsetMode === "utc" ? "home" : "utc" }) }
|
||||
function toggleUnits() { persistSettings({ units: units === "C" ? "F" : "C" }) }
|
||||
|
||||
function offsetTextFor(rowData) {
|
||||
if (!rowData || !rowData.ready) return ""
|
||||
return offsetMode === "utc" ? Model.utcOffsetLabel(rowData.offsetMinutes) : rowData.relative
|
||||
}
|
||||
|
||||
// ---- state
|
||||
property var probe: ({})
|
||||
property var searchProbe: ({})
|
||||
property string zoneCatalogText: ""
|
||||
property var facts: ({})
|
||||
// Geocoder answers are final for the session; fallbacks are retried.
|
||||
property var geo: ({})
|
||||
property var fallbackGeo: ({})
|
||||
property var weather: ({})
|
||||
// Cities jumped to on the globe; never persisted.
|
||||
property var sessionCities: []
|
||||
property int localOffsetMinutes: -(new Date().getTimezoneOffset())
|
||||
property string localZone: ""
|
||||
property double nowMs: Date.now()
|
||||
property bool probeQueued: false
|
||||
property bool searchProbeQueued: false
|
||||
property bool factsQueued: false
|
||||
|
||||
readonly property bool adding: citySearch.active
|
||||
onAddingChanged: if (!adding) scroller.scrollToTop()
|
||||
readonly property var zoneOptions: Model.zoneOptions(zoneCatalogText, zones)
|
||||
// The globe's jump box may go to a city that is already tracked.
|
||||
readonly property var allZoneOptions: Model.zoneOptions(zoneCatalogText, [])
|
||||
readonly property var searchZoneIds: {
|
||||
var out = []
|
||||
for (var i = 0; i < citySearch.matches.length; i++) {
|
||||
var id = citySearch.matches[i].value
|
||||
if (out.indexOf(id) < 0) out.push(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
onSearchZoneIdsChanged: probeSearchZones()
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
||||
readonly property color dim: Qt.darker(foreground, 1.55)
|
||||
readonly property color fainter: Qt.darker(foreground, 2.1)
|
||||
// A literal gold: several themes' "yellow" is not yellow.
|
||||
readonly property color daylightMarker: "#E5C736"
|
||||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||
|
||||
// ---- the zoom
|
||||
// One number drives the list-to-globe transition: 0 is the list, 1 the globe.
|
||||
property bool globeMode: false
|
||||
property real zoom: globeMode ? 1 : 0
|
||||
readonly property bool zoomIdle: zoom === 0 || zoom === 1
|
||||
readonly property real bigGlobeOpacity: Util.clamp(zoom / 0.12, 0, 1)
|
||||
readonly property real globeStageHeight: Style.space(378)
|
||||
// Far enough for the top row to fall past the whole list.
|
||||
readonly property real knockFall: listWrap.implicitHeight + Style.space(220)
|
||||
|
||||
// Shift-click the globe to run the transition at a third of the speed.
|
||||
readonly property int slowMotionFactor: 3
|
||||
// Set before globeMode flips, so the animation never reads a stale duration.
|
||||
property int zoomDuration: 800
|
||||
property int zoomEasing: Easing.OutQuart
|
||||
|
||||
function setGlobeMode(on, slow) {
|
||||
zoomDuration = (on ? 800 : 500) * (slow === true ? slowMotionFactor : 1)
|
||||
zoomEasing = on ? Easing.OutQuart : Easing.InOutCubic
|
||||
globeMode = on
|
||||
if (on && globeLoader.item) showFocusOnGlobe()
|
||||
}
|
||||
|
||||
// The globe opens on the list's focus, when its coordinates are known.
|
||||
function showFocusOnGlobe() {
|
||||
var g = globeLoader.item
|
||||
if (!g) return
|
||||
if (focusIndex >= 0 && focusKnown) g.goTo(focusZone.label, focusZone.id)
|
||||
else g.showHome()
|
||||
}
|
||||
|
||||
function focusFromGlobe(label, zone) {
|
||||
var i = Model.indexOfZone(zones, label, zone)
|
||||
if (i >= 0) focusOn(i)
|
||||
}
|
||||
|
||||
// The little globe's center in stage coordinates, where the big one grows
|
||||
// from. The unused sum makes it recompute when the layout above moves.
|
||||
readonly property var heroCenter: {
|
||||
var _ = hero.icon.width + hero.icon.height + stage.y + stage.width
|
||||
var pt = hero.icon.mapToItem(stage, hero.icon.width / 2, hero.icon.height / 2)
|
||||
return (pt && pt.x !== undefined) ? pt : null
|
||||
}
|
||||
readonly property real heroCenterX: heroCenter ? heroCenter.x : stage.width / 2
|
||||
readonly property real heroCenterY: heroCenter ? heroCenter.y : 0
|
||||
readonly property real heroDiscRadius: Math.max(1, hero.icon.width / 2 - 1)
|
||||
|
||||
// ---- drag to reorder
|
||||
// Rows move by transform while dragging; the order is committed on drop.
|
||||
property int dragIndex: -1
|
||||
property real dragOffset: 0
|
||||
property real rowPitch: 0
|
||||
property int dragTarget: -1
|
||||
|
||||
function beginRowDrag(index, pitch) {
|
||||
dropAnimation.stop()
|
||||
dragIndex = index
|
||||
dragTarget = index
|
||||
rowPitch = pitch
|
||||
}
|
||||
|
||||
function moveRowDrag(offset) {
|
||||
dragOffset = offset
|
||||
dragTarget = Model.dragTargetFor(dragIndex, dragOffset, rowPitch, dragTarget, zones.length)
|
||||
}
|
||||
|
||||
// Glide into the slot, then commit.
|
||||
function releaseRowDrag() {
|
||||
if (dragIndex < 0) { cancelRowDrag(); return }
|
||||
dropAnimation.to = (dragTarget - dragIndex) * rowPitch
|
||||
dropAnimation.restart()
|
||||
}
|
||||
|
||||
function commitRowDrag() {
|
||||
if (dragIndex >= 0 && dragTarget >= 0 && dragTarget !== dragIndex)
|
||||
persistSettings({ zones: Model.serializeZones(Model.moveZone(zones, dragIndex, dragTarget)) })
|
||||
cancelRowDrag()
|
||||
}
|
||||
|
||||
function cancelRowDrag() {
|
||||
dropAnimation.stop()
|
||||
dragIndex = -1
|
||||
dragTarget = -1
|
||||
dragOffset = 0
|
||||
}
|
||||
|
||||
// ---- scrubbing
|
||||
// Set absolutely from the pointer, held briefly after release, then back to now.
|
||||
property real scrubMinutes: 0
|
||||
readonly property double effectiveMs: nowMs + scrubMinutes * 60000
|
||||
readonly property string scrubLabel: Model.formatScrubDelta(scrubMinutes)
|
||||
|
||||
// Measured against the unscrubbed present, so a drag cannot drift.
|
||||
function scrubTo(rowData, fraction) {
|
||||
if (!rowData || !rowData.ready) return
|
||||
var parts = Model.zoneParts(nowMs, rowData.offsetMinutes)
|
||||
scrubMinutes = Model.scrubDeltaMinutes(fraction, parts.hour * 60 + parts.minute)
|
||||
}
|
||||
|
||||
function beginScrub(rowData, fraction) {
|
||||
scrubHold.stop()
|
||||
scrubTo(rowData, fraction)
|
||||
}
|
||||
|
||||
function endScrub() { scrubHold.restart() }
|
||||
|
||||
// ---- the moon
|
||||
// Shift-click a moon marker to walk the phase through a lunation.
|
||||
property bool moonShowing: false
|
||||
property real moonDemo: -1
|
||||
readonly property var moonShowStops: [0, 0.12, 0.25, 0.38, 0.5, 0.62, 0.75, 0.88, 1]
|
||||
property int moonShowStep: 0
|
||||
readonly property real moonPhase: moonShowing && moonDemo >= 0 ? moonDemo : Solar.moonPhase(effectiveMs)
|
||||
|
||||
function startMoonShow() {
|
||||
moonShowStep = 0
|
||||
moonShowing = true
|
||||
moonDemo = moonShowStops[0]
|
||||
moonShowTimer.restart()
|
||||
}
|
||||
|
||||
function stopMoonShow() {
|
||||
moonShowTimer.stop()
|
||||
// Before the value, so the tween does not run the phase back to -1.
|
||||
moonShowing = false
|
||||
moonDemo = -1
|
||||
}
|
||||
|
||||
// ---- places
|
||||
readonly property var clockRows: Model.rows(zones, probe, effectiveMs, localOffsetMinutes, hour24)
|
||||
|
||||
// The system zone names a representative city; `homeCity` overrides it.
|
||||
readonly property string homeCity: {
|
||||
var override = String(setting("homeCity", "")).trim()
|
||||
if (override !== "") return override
|
||||
return localZone === "" ? "" : Model.labelForZoneId(localZone)
|
||||
}
|
||||
readonly property var homeZone: ({ label: homeCity, id: localZone })
|
||||
|
||||
function placeOf(zone) {
|
||||
var f = facts[Model.factsKey(zone)]
|
||||
return (f && f.lat !== undefined && f.lon !== undefined) ? f : null
|
||||
}
|
||||
|
||||
readonly property var homePlace: homeCity === "" || localZone === "" ? null : placeOf(homeZone)
|
||||
|
||||
// Matched by name: Miami should not light up New York.
|
||||
readonly property var trackedNames: zones.map(function(z) { return String(z.label).toLowerCase() })
|
||||
|
||||
function globeRows(list, rank) {
|
||||
var out = []
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var f = placeOf(list[i])
|
||||
if (f) out.push([list[i].label, list[i].id, f.lat, f.lon, rank])
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property var trackedCities: globeRows(zones, 0)
|
||||
readonly property var sessionPlaces: globeRows(sessionCities, 1)
|
||||
|
||||
// Held as the city's key, not a row index, so a reorder carries the focus
|
||||
// with the city and a removal drops it back to home.
|
||||
property string focusKey: ""
|
||||
readonly property int focusIndex: Model.indexOfZoneKey(zones, focusKey)
|
||||
readonly property var focusZone: focusIndex >= 0 ? zones[focusIndex] : homeZone
|
||||
readonly property var focusPlace: placeOf(focusZone)
|
||||
readonly property bool focusKnown: focusPlace !== null
|
||||
readonly property real focusLat: focusKnown ? focusPlace.lat : 0
|
||||
readonly property real focusLon: focusKnown ? focusPlace.lon : 0
|
||||
|
||||
function focusOn(index) {
|
||||
var from = hero.spin
|
||||
focusKey = index >= 0 && index < zones.length ? Model.factsKey(zones[index]) : ""
|
||||
if (focusKnown) hero.turn(from, focusLon)
|
||||
}
|
||||
|
||||
// Surrogate pairs rather than literal glyphs, which re-encoding can break.
|
||||
function weatherGlyph(zone) {
|
||||
var f = facts[Model.factsKey(zone)]
|
||||
if (!f || f.w === undefined) return ""
|
||||
var kind = Model.weatherKind(f.w)
|
||||
if (kind === "sunny") return "\udb81\udda8" // white-balance-sunny: a solid disc
|
||||
if (kind === "partly") return "\udb81\udd95"
|
||||
if (kind === "cloudy") return "\udb81\udd90"
|
||||
if (kind === "rain") return "\udb81\udd97"
|
||||
if (kind === "snow") return "\udb81\udd98"
|
||||
return ""
|
||||
}
|
||||
|
||||
readonly property string localTime: {
|
||||
var here = Model.localParts(effectiveMs)
|
||||
var text = Model.formatTime(here, hour24)
|
||||
return hour24 ? text : text + " " + Model.meridiem(here)
|
||||
}
|
||||
|
||||
// No full stop: a period hanging off the arc reads as a speck.
|
||||
readonly property string hereLine: "It's " + localTime + " here"
|
||||
+ (homeCity !== "" ? " in " + homeCity : "")
|
||||
+ (scrubLabel !== "" ? " " + scrubLabel : "")
|
||||
|
||||
// A capital's line box has empty space above it; the row's top pad drops by it.
|
||||
FontMetrics {
|
||||
id: nameFontMetrics
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
}
|
||||
|
||||
TextMetrics {
|
||||
id: nameCapMetrics
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
text: "M"
|
||||
}
|
||||
|
||||
readonly property real capGap: Math.max(0,
|
||||
nameFontMetrics.height - nameFontMetrics.descent + nameCapMetrics.tightBoundingRect.y)
|
||||
|
||||
function tick() {
|
||||
nowMs = Date.now()
|
||||
localOffsetMinutes = -(new Date().getTimezoneOffset())
|
||||
}
|
||||
|
||||
// Written straight back to this widget's shell.json entry, like the clock's.
|
||||
function persistSettings(values) {
|
||||
var entry = { id: root.moduleName }
|
||||
for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]
|
||||
for (var key in values) entry[key] = values[key]
|
||||
|
||||
root.settings = entry
|
||||
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
|
||||
root.bar.shell.updateEntryInline(root.moduleName, entry)
|
||||
}
|
||||
|
||||
function setZones(next) {
|
||||
if (next === zones) return
|
||||
persistSettings({ zones: Model.serializeZones(next) })
|
||||
refresh()
|
||||
refreshFacts()
|
||||
}
|
||||
|
||||
// The label matters as much as the zone: many cities share one.
|
||||
function addCity(id, label) { setZones(Model.addZone(zones, id, label || "")) }
|
||||
function removeCity(id) { setZones(Model.removeZone(zones, id)) }
|
||||
function removeCityAt(index) { setZones(Model.removeZoneAt(zones, index)) }
|
||||
|
||||
function startAdding() { citySearch.start() }
|
||||
|
||||
function addSessionCity(label, id) {
|
||||
for (var i = 0; i < sessionCities.length; i++)
|
||||
if (sessionCities[i].label === label && sessionCities[i].id === id) return
|
||||
sessionCities = sessionCities.concat([{ label: label, id: id }])
|
||||
refreshFacts()
|
||||
}
|
||||
|
||||
// The local city plus four places spread round the clock from it.
|
||||
function seedFirstRun() {
|
||||
var offs = ({})
|
||||
for (var id in probe) offs[id] = probe[id].offsetMinutes
|
||||
|
||||
var seeded = localZone === "" ? []
|
||||
: Model.seedZones({ label: Model.labelForZoneId(localZone), id: localZone }, offs, 4)
|
||||
if (seeded.length === 0) seeded = Model.parseZones(Model.DEFAULT_ZONES)
|
||||
if (seeded.length === 0) return
|
||||
|
||||
persistSettings({ zones: Model.serializeZones(seeded) })
|
||||
refresh()
|
||||
refreshFacts()
|
||||
}
|
||||
|
||||
// ---- processes
|
||||
// Each refresh queues behind one in flight rather than being dropped.
|
||||
function refresh() {
|
||||
if (probeIds.length === 0) return
|
||||
if (probeProc.running) { probeQueued = true; return }
|
||||
probeProc.running = true
|
||||
}
|
||||
|
||||
function probeSearchZones() {
|
||||
if (searchZoneIds.length === 0) return
|
||||
if (searchProc.running) { searchProbeQueued = true; return }
|
||||
searchProc.running = true
|
||||
}
|
||||
|
||||
function refreshFacts() {
|
||||
if (zones.length === 0) return
|
||||
if (geoProc.running || weatherProc.running) { factsQueued = true; return }
|
||||
var missing = factsRequest.filter(function(row) { return !geo[Model.factsKey(row)] })
|
||||
if (missing.length === 0) { fetchWeather(); return }
|
||||
geoProc.rows = missing
|
||||
geoProc.command = ["bash", "-c", 'for url; do curl -fsS --max-time 8 "$url"; printf "\\n\\036\\n"; done', "bash"]
|
||||
.concat(missing.map(function(row) { return Model.geocodeUrl(row.label) }))
|
||||
geoProc.running = true
|
||||
}
|
||||
|
||||
function coordsFor(key) {
|
||||
return geo[key] || fallbackGeo[key] || null
|
||||
}
|
||||
|
||||
function fetchWeather() {
|
||||
var now = Date.now()
|
||||
var stale = factsRequest.map(Model.factsKey).filter(function(key) {
|
||||
return coordsFor(key) && Model.weatherStale(weather[key], now)
|
||||
})
|
||||
if (stale.length === 0) { publishFacts(); return }
|
||||
weatherProc.keys = stale
|
||||
weatherProc.command = ["curl", "-fsS", "--max-time", "8", Model.forecastUrl(stale.map(coordsFor))]
|
||||
weatherProc.running = true
|
||||
}
|
||||
|
||||
function publishFacts() {
|
||||
var coords = {}
|
||||
var keys = factsRequest.map(Model.factsKey)
|
||||
keys.forEach(function(key) { coords[key] = coordsFor(key) })
|
||||
facts = Model.mergeFacts(keys, coords, weather)
|
||||
if (!factsQueued) return
|
||||
factsQueued = false
|
||||
Qt.callLater(refreshFacts)
|
||||
}
|
||||
|
||||
function loadCatalog() {
|
||||
if (zoneCatalogText !== "" || catalogProc.running) return
|
||||
catalogProc.running = true
|
||||
}
|
||||
|
||||
function utcLabelFor(zoneId) {
|
||||
var known = searchProbe[zoneId]
|
||||
return known === undefined ? "" : Model.utcOffsetLabel(known.offsetMinutes)
|
||||
}
|
||||
|
||||
// Tracked cities, the city you are in (for the globe's home) and session cities.
|
||||
readonly property var factsRequest: {
|
||||
var out = zones.map(function(z) { return { label: z.label, id: z.id } })
|
||||
if (localZone !== "" && homeCity !== "") out.push({ label: homeCity, id: localZone })
|
||||
return out.concat(sessionCities)
|
||||
}
|
||||
|
||||
// ---- opening spin
|
||||
// It lands on the home meridian, so it waits for home's coordinates, read
|
||||
// straight from the facts: focusKnown can flip a beat before focusLon lands.
|
||||
property bool spinPending: false
|
||||
|
||||
function homeMeridian() {
|
||||
var f = facts[Model.factsKey(focusZone)]
|
||||
return (f && f.lon !== undefined && f.lon !== null) ? f.lon : null
|
||||
}
|
||||
|
||||
function startOpeningSpin() {
|
||||
var lon = homeMeridian()
|
||||
if (lon === null) { spinPending = true; spinFallback.restart(); return }
|
||||
spinPending = false
|
||||
spinFallback.stop()
|
||||
hero.spinOpening(lon)
|
||||
}
|
||||
|
||||
onFactsChanged: if (spinPending) startOpeningSpin()
|
||||
|
||||
// No coordinates on a cold cache without network: spin anyway.
|
||||
Timer {
|
||||
id: spinFallback
|
||||
interval: 1500
|
||||
onTriggered: if (root.spinPending) { root.spinPending = false; hero.spinOpening(null) }
|
||||
}
|
||||
|
||||
onOpenedChanged: {
|
||||
if (opened) {
|
||||
focusKey = ""
|
||||
tick(); refresh(); refreshFacts(); loadCatalog()
|
||||
startOpeningSpin()
|
||||
} else {
|
||||
citySearch.stop(); setGlobeMode(false, false); scrubMinutes = 0
|
||||
spinPending = false; spinFallback.stop()
|
||||
}
|
||||
}
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Process {
|
||||
id: probeProc
|
||||
command: Model.probeCommand(root.probeIds, true)
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.probe = Model.parseProbe(text)
|
||||
var lz = Model.localZoneFromProbe(text)
|
||||
if (lz !== "") root.localZone = lz
|
||||
if (root.needsSeed) root.seedFirstRun()
|
||||
root.tick()
|
||||
Qt.callLater(function() {
|
||||
if (!root.probeQueued) return
|
||||
root.probeQueued = false
|
||||
root.refresh()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merged, so a known offset stays on screen while the next probe runs.
|
||||
Process {
|
||||
id: searchProc
|
||||
command: Model.probeCommand(root.searchZoneIds, false)
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var merged = {}
|
||||
for (var known in root.searchProbe) merged[known] = root.searchProbe[known]
|
||||
var fresh = Model.parseProbe(text)
|
||||
for (var id in fresh) merged[id] = fresh[id]
|
||||
root.searchProbe = merged
|
||||
Qt.callLater(function() {
|
||||
if (!root.searchProbeQueued) return
|
||||
root.searchProbeQueued = false
|
||||
root.probeSearchZones()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: catalogProc
|
||||
command: ["timedatectl", "list-timezones"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.zoneCatalogText = text
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: zoneTab
|
||||
path: "/usr/share/zoneinfo/zone1970.tab"
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: geoProc
|
||||
property var rows: []
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var answers = text.split("\n\u001e\n")
|
||||
var nextGeo = Object.assign({}, root.geo)
|
||||
var nextFallback = {}
|
||||
geoProc.rows.forEach(function(row, i) {
|
||||
var key = Model.factsKey(row)
|
||||
var found = Model.pickGeocode(answers[i] || "", row.id)
|
||||
var place = found.place || Model.zoneTabCoords(zoneTab.text(), row.id)
|
||||
if (!place) return
|
||||
if (found.answered) nextGeo[key] = place
|
||||
else nextFallback[key] = place
|
||||
})
|
||||
root.geo = nextGeo
|
||||
root.fallbackGeo = nextFallback
|
||||
root.fetchWeather()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: weatherProc
|
||||
property var keys: []
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.weather = Object.assign({}, root.weather, Model.parseForecast(text, weatherProc.keys, Date.now()))
|
||||
root.publishFacts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 900000
|
||||
running: root.opened
|
||||
repeat: true
|
||||
onTriggered: root.refreshFacts()
|
||||
}
|
||||
|
||||
// Heavy on the way out, brisk on the way back.
|
||||
Behavior on zoom {
|
||||
NumberAnimation {
|
||||
duration: root.zoomDuration
|
||||
easing.type: root.zoomEasing
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: moonShowTimer
|
||||
interval: 620
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
root.moonShowStep++
|
||||
if (root.moonShowStep >= root.moonShowStops.length) root.stopMoonShow()
|
||||
else root.moonDemo = root.moonShowStops[root.moonShowStep]
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on moonDemo {
|
||||
enabled: root.moonShowing
|
||||
NumberAnimation { duration: 460; easing.type: Easing.InOutSine }
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: dropAnimation
|
||||
target: root
|
||||
property: "dragOffset"
|
||||
duration: 150
|
||||
easing.type: Easing.OutCubic
|
||||
onFinished: root.commitRowDrag()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: scrubHold
|
||||
interval: 2500
|
||||
onTriggered: root.scrubMinutes = 0
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: root.opened
|
||||
repeat: true
|
||||
onTriggered: root.tick()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 300000
|
||||
running: root.opened
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: root.ipcTarget
|
||||
function open(): void { root.open() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.open() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.toggle() }
|
||||
function refresh(): string { root.refresh(); return "ok" }
|
||||
|
||||
function globe(): string {
|
||||
if (!root.globeEnabled) return "disabled"
|
||||
root.setGlobeMode(!root.globeMode, false)
|
||||
return root.globeMode ? "globe" : "list"
|
||||
}
|
||||
|
||||
// Ticks first: the clock only runs while the panel is open.
|
||||
function times(): string {
|
||||
root.tick()
|
||||
return JSON.stringify(root.clockRows)
|
||||
}
|
||||
function add(zone: string, label: string): string {
|
||||
root.setZones(Model.addZone(root.zones, zone, label))
|
||||
return Model.serializeZones(root.zones)
|
||||
}
|
||||
function remove(zone: string): string { root.removeCity(zone); return Model.serializeZones(root.zones) }
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: ""
|
||||
tooltipText: "World clock"
|
||||
onPressed: function(buttonCode) {
|
||||
if (buttonCode === Qt.RightButton || buttonCode === Qt.MiddleButton) root.refresh()
|
||||
else root.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
focusTarget: keyCatcher
|
||||
contentWidth: panel.fittedContentWidth(Style.space(340))
|
||||
contentHeight: panel.fittedContentHeight(content.implicitHeight, Style.space(680))
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
blocked: root.adding
|
||||
// Escape unwinds one layer: search (its own field), globe, then panel.
|
||||
onCloseRequested: {
|
||||
if (root.globeMode) root.setGlobeMode(false, false)
|
||||
else root.close()
|
||||
}
|
||||
// Space toggles the globe. Return also arrives as an activate, which it skips.
|
||||
property bool returnHandled: false
|
||||
onReturnRequested: returnHandled = true
|
||||
onActivateRequested: {
|
||||
if (returnHandled) { returnHandled = false; return }
|
||||
if (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.
|
||||
onTextKey: function(text) {
|
||||
var key = text.toLowerCase()
|
||||
if (key === "r") root.refresh()
|
||||
else if (root.globeMode && (key === "+" || key === "j")) {
|
||||
if (globeLoader.item) globeLoader.item.startJump()
|
||||
} else if (!root.globeMode && (key === "+" || key === "a")) {
|
||||
root.startAdding()
|
||||
}
|
||||
}
|
||||
|
||||
// Clipped to the card and scrolled by the wheel only: the rows own drags.
|
||||
Flickable {
|
||||
id: scroller
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
interactive: false
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
readonly property real maxScroll: Math.max(0, contentHeight - height)
|
||||
|
||||
function clamp() { contentY = Util.clamp(contentY, 0, maxScroll) }
|
||||
onHeightChanged: clamp()
|
||||
onContentHeightChanged: clamp()
|
||||
|
||||
function scrollTo(y) {
|
||||
scrollAnim.stop()
|
||||
scrollAnim.from = contentY
|
||||
scrollAnim.to = y
|
||||
scrollAnim.start()
|
||||
}
|
||||
function scrollToTop() { scrollTo(0) }
|
||||
|
||||
// Follows maxScroll: the results arrive over several frames after the
|
||||
// search opens, so a one-shot scroll lands short.
|
||||
onMaxScrollChanged: if (root.adding) scrollTo(maxScroll)
|
||||
|
||||
// Not a Behavior: the wheel writes contentY too and must track directly.
|
||||
NumberAnimation {
|
||||
id: scrollAnim
|
||||
target: scroller
|
||||
property: "contentY"
|
||||
duration: 160
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: function(event) {
|
||||
var d = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 120 * Style.space(40)
|
||||
scroller.contentY -= d
|
||||
scroller.clamp()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: scroller.width
|
||||
spacing: Style.spacing.panelGap
|
||||
|
||||
HeroTitle {
|
||||
id: hero
|
||||
width: parent.width
|
||||
caption: root.hereLine
|
||||
// Accent while scrubbed, so a shifted time is never taken for now.
|
||||
captionColor: root.scrubMinutes !== 0 ? Color.accent : root.dim
|
||||
captionClickable: root.focusIndex >= 0
|
||||
foreground: root.foreground
|
||||
dim: root.dim
|
||||
fontFamily: root.fontFamily
|
||||
zoom: root.zoom
|
||||
globeEnabled: root.globeEnabled
|
||||
bigGlobeOpacity: root.bigGlobeOpacity
|
||||
restLon: root.focusLon
|
||||
markerShown: root.focusKnown
|
||||
markerLat: root.focusLat
|
||||
markerLon: root.focusLon
|
||||
onCaptionClicked: root.focusOn(-1)
|
||||
onGlobeClicked: function(slow) { root.setGlobeMode(!root.globeMode, slow) }
|
||||
}
|
||||
|
||||
// ---- stage
|
||||
// The list and the globe share it; it grows with the zoom. Not
|
||||
// clipped, so the globe can start from the header above it.
|
||||
Item {
|
||||
id: stage
|
||||
width: parent.width
|
||||
height: Math.max(1, listWrap.implicitHeight
|
||||
+ (root.globeStageHeight - listWrap.implicitHeight) * root.zoom)
|
||||
|
||||
// Clipped, so knocked rows fall out of the panel.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: listWrap
|
||||
width: parent.width
|
||||
spacing: Style.spacing.panelGap
|
||||
|
||||
Column {
|
||||
visible: root.zoom < 1
|
||||
width: parent.width
|
||||
spacing: Style.spacing.md
|
||||
|
||||
// The zone list, not clockRows: that ticks, and would
|
||||
// rebuild every delegate mid-drag.
|
||||
Repeater {
|
||||
model: root.zones
|
||||
|
||||
CityRow { panel: root }
|
||||
}
|
||||
}
|
||||
|
||||
CitySearch {
|
||||
id: citySearch
|
||||
visible: root.zoom < 1
|
||||
width: parent.width
|
||||
options: root.zoneOptions
|
||||
loading: root.zoneCatalogText === ""
|
||||
offsetLabel: function(zoneId) { return root.utcLabelFor(zoneId) }
|
||||
foreground: root.foreground
|
||||
dim: root.dim
|
||||
fainter: root.fainter
|
||||
fontFamily: root.fontFamily
|
||||
onActiveChanged: if (active) root.loadCatalog()
|
||||
onPicked: function(label, id) { root.addCity(id, label) }
|
||||
onDismissed: Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
|
||||
// Knocked aside last, after every row.
|
||||
transform: Translate {
|
||||
x: Model.knockX(root.zones.length, root.zoom, Style.space(95))
|
||||
y: Model.knockY(root.zones.length, root.zoom, root.knockFall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- globe
|
||||
// Loaded early on hover and kept for the whole flight, so reading
|
||||
// its data never lands mid-animation.
|
||||
Loader {
|
||||
id: globeLoader
|
||||
active: root.globeEnabled && (root.globeMode || root.zoom > 0 || hero.iconHovered)
|
||||
visible: root.zoom > 0
|
||||
opacity: root.bigGlobeOpacity
|
||||
width: parent.width
|
||||
height: root.globeStageHeight
|
||||
source: "Globe.qml"
|
||||
|
||||
// The disc's center, above the globe's footer and jump bar.
|
||||
readonly property real discCX: width / 2
|
||||
readonly property real discCY: item ? (height - item.footerHeight - item.jumpHeight) / 2 : height / 2
|
||||
readonly property real discR: item && item.radius > 0 ? item.radius : 1
|
||||
readonly property real startScale: root.heroDiscRadius / discR
|
||||
readonly property real zoomScale: startScale + (1 - startScale) * root.zoom
|
||||
|
||||
// Scaled about the disc, then carried from the little globe.
|
||||
transform: [
|
||||
Scale {
|
||||
origin.x: globeLoader.discCX
|
||||
origin.y: globeLoader.discCY
|
||||
xScale: globeLoader.zoomScale
|
||||
yScale: globeLoader.zoomScale
|
||||
},
|
||||
Translate {
|
||||
x: (root.heroCenterX - globeLoader.discCX) * (1 - root.zoom)
|
||||
y: (root.heroCenterY - globeLoader.discCY) * (1 - root.zoom)
|
||||
}
|
||||
]
|
||||
|
||||
onLoaded: {
|
||||
item.foreground = Qt.binding(function() { return root.foreground })
|
||||
item.dim = Qt.binding(function() { return root.dim })
|
||||
item.fainter = Qt.binding(function() { return root.fainter })
|
||||
item.daylightMarker = Qt.binding(function() { return root.daylightMarker })
|
||||
item.moonPhase = Qt.binding(function() { return root.moonPhase })
|
||||
item.fontFamily = Qt.binding(function() { return root.fontFamily })
|
||||
item.hour24 = Qt.binding(function() { return root.hour24 })
|
||||
item.trackedNames = Qt.binding(function() { return root.trackedNames })
|
||||
item.trackedCities = Qt.binding(function() { return root.trackedCities })
|
||||
item.sessionCities = Qt.binding(function() { return root.sessionPlaces })
|
||||
item.homeRow = Qt.binding(function() {
|
||||
var home = root.homePlace
|
||||
return home ? [root.homeCity, root.localZone, home.lat, home.lon, 0] : []
|
||||
})
|
||||
item.offsetMode = Qt.binding(function() { return root.offsetMode })
|
||||
item.homeOffsetMinutes = Qt.binding(function() { return root.localOffsetMinutes })
|
||||
item.smoothMotion = Qt.binding(function() { return root.smoothMotion })
|
||||
item.transitioning = Qt.binding(function() { return !root.zoomIdle })
|
||||
item.zoomLevel = Qt.binding(function() { return root.zoom })
|
||||
// The footer and jump bar arrive once the globe has landed.
|
||||
item.chromeOpacity = Qt.binding(function() { return Util.clamp((root.zoom - 0.74) / 0.26, 0, 1) })
|
||||
item.jumpOptions = Qt.binding(function() { return root.allZoneOptions })
|
||||
item.offsetModeToggleRequested.connect(function() { root.toggleOffsetMode() })
|
||||
item.jumpRequested.connect(function(label, zone) { root.addSessionCity(label, zone) })
|
||||
item.exitRequested.connect(function() { root.setGlobeMode(false, false) })
|
||||
item.jumpDismissed.connect(function() { Qt.callLater(function() { keyCatcher.forceActiveFocus() }) })
|
||||
item.citySelected.connect(function(label, zone) { root.focusFromGlobe(label, zone) })
|
||||
// A cold start can finish loading after the mode switched on.
|
||||
if (root.globeMode) root.showFocusOnGlobe()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user