Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,881 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "blob.weather"
|
||||
ipcTarget: "blob.weather"
|
||||
manageIpc: false
|
||||
|
||||
property var anchorItem: null
|
||||
property bool openedFromHotkey: false
|
||||
|
||||
// The bar tracks the widget mounted in its slot — BarWidget.qml — not this
|
||||
// nested panel. Everything the bar identifies a panel by has to be that
|
||||
// widget: the popout coordinator (and with it the open-panel dot under the
|
||||
// pill) compares against `slot.activeItem`, and switchPanelFrom looks the
|
||||
// slot up the same way.
|
||||
property var hostWidget: null
|
||||
readonly property var barIdentity: hostWidget || root
|
||||
|
||||
function open() {
|
||||
openedFromHotkey = false
|
||||
setCenterHoverRevealSuppressed(false)
|
||||
root.controller.show()
|
||||
locationFile.reload()
|
||||
root.refresh()
|
||||
}
|
||||
|
||||
function openFromHotkey() {
|
||||
openedFromHotkey = true
|
||||
root.controller.show()
|
||||
locationFile.reload()
|
||||
root.refresh()
|
||||
// Set after showing, not before: showing hands the popout coordinator
|
||||
// over, which closes whichever panel was open, and that close clears the
|
||||
// shared flag. Deferring means the panel taking over always wins, while
|
||||
// a handoff to a panel that does not manage the flag still leaves it
|
||||
// cleared rather than stuck on.
|
||||
Qt.callLater(function() {
|
||||
if (root.opened) setCenterHoverRevealSuppressed(true)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
setCenterHoverRevealSuppressed(false)
|
||||
if (root.editingLocation) root.cancelEditingLocation()
|
||||
root.controller.hide()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.opened) root.close()
|
||||
else root.openFromHotkey()
|
||||
}
|
||||
|
||||
function switchPanel(direction) {
|
||||
if (root.bar && typeof root.bar.switchPanelFrom === "function")
|
||||
return root.bar.switchPanelFrom(root.barIdentity, direction)
|
||||
return false
|
||||
}
|
||||
|
||||
function setCenterHoverRevealSuppressed(value) {
|
||||
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
|
||||
root.bar.setCenterHoverRevealSuppressed(value)
|
||||
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||
root.bar.centerHoverRevealSuppressed = value
|
||||
}
|
||||
|
||||
// Parsed wttr.in j1 response. Kept on failure so stale data stays visible.
|
||||
property var report: null
|
||||
property var dailyForecastReport: null
|
||||
property string wttrLocation: ""
|
||||
|
||||
// Configured location, read from the weather.json state file (owned by
|
||||
// blob-weather-location). The query is the wttr.in path segment
|
||||
// (coordinates when stored, else the encoded name); empty means IP
|
||||
// auto-detect. The watch makes hand edits take effect live.
|
||||
property var configuredLocationState: ({ name: "", latitude: null, longitude: null })
|
||||
readonly property string configuredLocation: configuredLocationState.name
|
||||
readonly property string locationQuery: Model.wttrLocationQuery(configuredLocationState.name, configuredLocationState.latitude, configuredLocationState.longitude)
|
||||
|
||||
// Keep the previous report visible while the new location loads. The
|
||||
// editor remains open with a spinner, so stale data is never presented
|
||||
// under the newly configured location label.
|
||||
onLocationQueryChanged: {
|
||||
if (savingLocation) savingLocationQueryStarted = true
|
||||
forecastRetries = 0
|
||||
dailyForecastRetries = 0
|
||||
forecastProc.running = false
|
||||
dailyForecastProc.running = false
|
||||
Qt.callLater(refresh)
|
||||
}
|
||||
|
||||
property FileView locationFile: FileView {
|
||||
path: Quickshell.env("HOME") + "/.local/state/blob/settings/weather.json"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: reload()
|
||||
onLoaded: root.configuredLocationState = Model.parseLocationFile(text())
|
||||
onLoadFailed: root.configuredLocationState = Model.parseLocationFile("")
|
||||
}
|
||||
|
||||
// The first read can race shell startup (observed sporadically), leaving a
|
||||
// stored location unhonored until the next file write. One delayed reload
|
||||
// self-corrects; if the first read was fine it's a no-op, since identical
|
||||
// state doesn't change locationQuery and so triggers no refetch.
|
||||
Timer {
|
||||
interval: 1500
|
||||
running: true
|
||||
onTriggered: locationFile.reload()
|
||||
}
|
||||
|
||||
property int forecastRetries: 0
|
||||
property int dailyForecastRetries: 0
|
||||
|
||||
// Click-to-edit state for the location label.
|
||||
property bool editingLocation: false
|
||||
property bool savingLocation: false
|
||||
property bool savingLocationQueryStarted: false
|
||||
property var locationSuggestions: []
|
||||
property int suggestionIndex: 0
|
||||
property string geocodePendingQuery: ""
|
||||
property string geocodeActiveQuery: ""
|
||||
|
||||
// Shared hero/bar icon state, updated with each successful weather response.
|
||||
property string label: ""
|
||||
|
||||
// wttr's current conditions when available; open-meteo's (bundled with the
|
||||
// much faster daily forecast fetch) fill the hero while wttr is in flight.
|
||||
readonly property bool hasConfiguredCoordinates: !isNaN(parseFloat(String(configuredLocationState.latitude))) && !isNaN(parseFloat(String(configuredLocationState.longitude)))
|
||||
readonly property var openMeteoCurrent: Model.openMeteoCurrentCondition(dailyForecastReport)
|
||||
readonly property var current: (hasConfiguredCoordinates && openMeteoCurrent) ? openMeteoCurrent : ((report && report.current_condition && report.current_condition[0]) ? report.current_condition[0] : openMeteoCurrent)
|
||||
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
|
||||
readonly property var forecastDays: buildForecastDays()
|
||||
readonly property string reportCountry: areaInfo && areaInfo.country && areaInfo.country[0] ? areaInfo.country[0].value : ""
|
||||
|
||||
readonly property bool useImperial: Model.shouldUseImperial(setting("unit", ""), Qt.locale().name, reportCountry)
|
||||
|
||||
// Auto-refresh interval in minutes; clamped to a sane minimum.
|
||||
readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 15), 10) || 15)
|
||||
|
||||
readonly property string reportLocation: configuredLocation || wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "")
|
||||
readonly property string reportTempNum: current ? String(useImperial ? current.temp_F : current.temp_C) : ""
|
||||
readonly property string tempUnit: "°" + (useImperial ? "F" : "C")
|
||||
readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : ""
|
||||
readonly property string reportWind: current ? (useImperial ? (current.windspeedMiles + " mph") : (current.windspeedKmph + " km/h")) : ""
|
||||
readonly property string reportHumidity: current ? (current.humidity + "%") : ""
|
||||
|
||||
function refresh() {
|
||||
// Each full refresh cycle gets a fresh retry budget, so an earlier
|
||||
// exhausted round (e.g. waking with the network still down) doesn't
|
||||
// starve retries for the rest of the session.
|
||||
forecastRetries = 0
|
||||
dailyForecastRetries = 0
|
||||
if (!forecastProc.running) forecastProc.running = true
|
||||
if (root.locationQuery === "" && !locationProc.running) locationProc.running = true
|
||||
// With stored coordinates this fetches open-meteo right away — no need
|
||||
// to wait for the slow wttr response. Without them it's a no-op until
|
||||
// wttr reports the detected area.
|
||||
refreshDailyForecast(null)
|
||||
}
|
||||
|
||||
function refreshDailyForecast(sourceReport) {
|
||||
if (dailyForecastProc.running) return
|
||||
|
||||
var lat = parseFloat(String(root.configuredLocationState.latitude))
|
||||
var lon = parseFloat(String(root.configuredLocationState.longitude))
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo
|
||||
if (!area) return
|
||||
lat = parseFloat(String(area.latitude || ""))
|
||||
lon = parseFloat(String(area.longitude || ""))
|
||||
}
|
||||
if (isNaN(lat) || isNaN(lon)) return
|
||||
|
||||
var url = "https://api.open-meteo.com/v1/forecast"
|
||||
+ "?latitude=" + encodeURIComponent(String(lat))
|
||||
+ "&longitude=" + encodeURIComponent(String(lon))
|
||||
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
|
||||
+ "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day"
|
||||
+ "&forecast_days=4"
|
||||
+ "&timezone=auto"
|
||||
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
|
||||
dailyForecastProc.running = true
|
||||
}
|
||||
|
||||
// ---- Location editing. Clicking the location label swaps it for a search
|
||||
// field; picking a geocoded suggestion persists name + coordinates to
|
||||
// the module's shell.json entry. An empty commit returns to auto.
|
||||
function startEditingLocation() {
|
||||
editingLocation = true
|
||||
savingLocation = false
|
||||
savingLocationQueryStarted = false
|
||||
locationSuggestions = []
|
||||
suggestionIndex = 0
|
||||
Qt.callLater(function() {
|
||||
locationField.text = root.configuredLocation
|
||||
locationField.selectAll()
|
||||
locationField.forceActiveFocus()
|
||||
})
|
||||
}
|
||||
|
||||
function cancelEditingLocation() {
|
||||
editingLocation = false
|
||||
savingLocation = false
|
||||
savingLocationQueryStarted = false
|
||||
locationSuggestions = []
|
||||
geocodeDebounce.stop()
|
||||
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
function commitLocation() {
|
||||
var location = Model.locationCommit(locationField.text, locationSuggestions, suggestionIndex)
|
||||
if (location.name === "") {
|
||||
clearLocation()
|
||||
return
|
||||
}
|
||||
savingLocation = true
|
||||
savingLocationQueryStarted = false
|
||||
configuredLocationState = {
|
||||
name: location.name,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude
|
||||
}
|
||||
persistLocation(location.name, location.latitude, location.longitude)
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
persistLocation("", null, null)
|
||||
wttrLocation = ""
|
||||
cancelEditingLocation()
|
||||
}
|
||||
|
||||
function pickSuggestion(suggestion) {
|
||||
if (!suggestion) return
|
||||
savingLocation = true
|
||||
savingLocationQueryStarted = false
|
||||
configuredLocationState = {
|
||||
name: suggestion.name,
|
||||
latitude: suggestion.latitude,
|
||||
longitude: suggestion.longitude
|
||||
}
|
||||
persistLocation(suggestion.name, suggestion.latitude, suggestion.longitude)
|
||||
}
|
||||
|
||||
function finishSavingLocation() {
|
||||
if (savingLocation && savingLocationQueryStarted) cancelEditingLocation()
|
||||
}
|
||||
|
||||
function persistLocation(name, latitude, longitude) {
|
||||
if (name && latitude !== null && longitude !== null)
|
||||
locationSaveProc.command = ["blob-weather-location", "--set", name, latitude + "," + longitude]
|
||||
else if (name)
|
||||
locationSaveProc.command = ["blob-weather-location", "--set", name]
|
||||
else
|
||||
locationSaveProc.command = ["blob-weather-location", "--clear"]
|
||||
locationSaveProc.running = true
|
||||
}
|
||||
|
||||
// Debounced geocoding. Only one curl runs at a time; if the query moved on
|
||||
// while a fetch was in flight, the latest query is fetched right after.
|
||||
function requestGeocode() {
|
||||
var query = locationField.text.trim()
|
||||
if (query.length < 2) {
|
||||
locationSuggestions = []
|
||||
return
|
||||
}
|
||||
geocodePendingQuery = query
|
||||
if (!geocodeProc.running) startGeocode()
|
||||
}
|
||||
|
||||
function startGeocode() {
|
||||
geocodeActiveQuery = geocodePendingQuery
|
||||
geocodeProc.command = ["curl", "-fsS", "--max-time", "5",
|
||||
"https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(geocodeActiveQuery) + "&count=5&language=en&format=json"]
|
||||
geocodeProc.running = true
|
||||
}
|
||||
|
||||
function buildForecastDays() {
|
||||
return Model.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||
}
|
||||
|
||||
function openMeteoForecastDays() {
|
||||
return Model.openMeteoForecastDays(dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||
}
|
||||
|
||||
function wttrNextForecastDays() {
|
||||
return Model.wttrNextForecastDays(report, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||
}
|
||||
|
||||
function isFutureForecastDate(dateString) {
|
||||
return Model.isFutureForecastDate(dateString, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||
}
|
||||
|
||||
function roundedTemp(value) {
|
||||
return Model.roundedTemp(value)
|
||||
}
|
||||
|
||||
function celsiusToFahrenheit(value) {
|
||||
return Model.celsiusToFahrenheit(value)
|
||||
}
|
||||
|
||||
function formatTemp(value) {
|
||||
return Model.formatTemp(value, useImperial)
|
||||
}
|
||||
|
||||
function dayName(dateString) {
|
||||
return Model.dayName(dateString, function(date) { return Qt.formatDate(date, "dddd") })
|
||||
}
|
||||
|
||||
// Bare degree value (no unit letter), used in the forecast row.
|
||||
function bareTempForDay(day, kind) {
|
||||
return Model.bareTempForDay(day, kind, useImperial)
|
||||
}
|
||||
|
||||
// Representative icon for a forecast day: the hourly entry nearest noon.
|
||||
function dayIcon(day) {
|
||||
return Model.dayIcon(day)
|
||||
}
|
||||
|
||||
function iconForOpenMeteoCode(code) {
|
||||
return Model.iconForOpenMeteoCode(code)
|
||||
}
|
||||
|
||||
// Mirrors blob-weather-icon's wttr.in code → nerd-font glyph mapping.
|
||||
function iconForCode(code, night) {
|
||||
return Model.iconForCode(code, night)
|
||||
}
|
||||
|
||||
Process {
|
||||
id: forecastProc
|
||||
command: ["curl", "-fsS", "--max-time", "10", "https://wttr.in/" + root.locationQuery + "?format=j1"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var raw = String(text || "").trim()
|
||||
if (!raw) {
|
||||
root.scheduleForecastRetry()
|
||||
return
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(raw)
|
||||
root.report = parsed
|
||||
if (!root.hasConfiguredCoordinates)
|
||||
root.label = Model.provisionalCurrentIcon(parsed.current_condition && parsed.current_condition[0], root.label)
|
||||
root.forecastRetries = 0
|
||||
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "wttr"))
|
||||
root.finishSavingLocation()
|
||||
// Stored coordinates already drove the fast open-meteo fetch from
|
||||
// refresh(); only auto-detect needs the area wttr reported.
|
||||
if (isNaN(parseFloat(String(root.configuredLocationState.latitude))))
|
||||
root.refreshDailyForecast(parsed)
|
||||
} catch (e) {
|
||||
// Keep last-good report visible, but try again shortly.
|
||||
root.scheduleForecastRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wttr.in can be slow or flaky, especially for a location it hasn't
|
||||
// cached yet. Retry a few times before leaving it to the refresh timer.
|
||||
function scheduleForecastRetry() {
|
||||
if (forecastRetries >= 3) return
|
||||
forecastRetries++
|
||||
forecastRetryTimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: forecastRetryTimer
|
||||
interval: 2500
|
||||
onTriggered: if (!forecastProc.running) forecastProc.running = true
|
||||
}
|
||||
|
||||
// With configured coordinates this fetch is the only thing that updates the
|
||||
// bar icon, so a dropped response (e.g. waking before the network is back)
|
||||
// must retry rather than wait out the refresh timer with a stale icon.
|
||||
function scheduleDailyForecastRetry() {
|
||||
if (dailyForecastRetries >= 3) return
|
||||
dailyForecastRetries++
|
||||
dailyForecastRetryTimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: dailyForecastRetryTimer
|
||||
interval: 2500
|
||||
onTriggered: root.refreshDailyForecast(null)
|
||||
}
|
||||
|
||||
Process {
|
||||
id: dailyForecastProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var raw = String(text || "").trim()
|
||||
if (!raw) {
|
||||
root.scheduleDailyForecastRetry()
|
||||
return
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(raw)
|
||||
var parsedCurrent = Model.openMeteoCurrentCondition(parsed)
|
||||
root.dailyForecastReport = parsed
|
||||
root.label = Model.currentIcon(parsedCurrent, root.label)
|
||||
root.dailyForecastRetries = 0
|
||||
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "open-meteo"))
|
||||
root.finishSavingLocation()
|
||||
} catch (e) {
|
||||
// Keep last-good daily forecast visible, but try again shortly.
|
||||
root.scheduleDailyForecastRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: geocodeProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
root.locationSuggestions = root.editingLocation ? Model.parseGeocodingResults(text) : []
|
||||
root.suggestionIndex = 0
|
||||
if (root.geocodePendingQuery !== root.geocodeActiveQuery) Qt.callLater(root.startGeocode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: geocodeDebounce
|
||||
interval: 300
|
||||
onTriggered: root.requestGeocode()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: locationSaveProc
|
||||
onExited: function(exitCode) {
|
||||
if (exitCode !== 0 || !root.savingLocation) return
|
||||
|
||||
// FileView handles changed locations. Explicitly refresh here too so
|
||||
// saving the already-active location cannot strand the spinner.
|
||||
locationFile.reload()
|
||||
if (!root.savingLocationQueryStarted) {
|
||||
root.savingLocationQueryStarted = true
|
||||
root.forecastRetries = 0
|
||||
root.dailyForecastRetries = 0
|
||||
forecastProc.running = false
|
||||
dailyForecastProc.running = false
|
||||
Qt.callLater(root.refresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: locationProc
|
||||
command: ["curl", "-fsS", "--max-time", "4", "https://wttr.in/?format=%l"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var raw = String(text || "").trim()
|
||||
if (!raw) return
|
||||
root.wttrLocation = raw.split(",")[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: root.refreshMinutes * 60 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: root.ipcTarget
|
||||
|
||||
function open(): void { root.openFromHotkey() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.openFromHotkey() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.toggle() }
|
||||
function edit(): void { root.openFromHotkey(); root.startEditingLocation() }
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: root.anchorItem
|
||||
owner: root.barIdentity
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
centerOnBar: true
|
||||
focusTarget: keyCatcher
|
||||
contentWidth: panel.fittedContentWidth(Style.space(480))
|
||||
contentHeight: panel.fittedContentHeight(weatherColumn.implicitHeight)
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
blocked: root.editingLocation
|
||||
onReturnRequested: root.startEditingLocation()
|
||||
onCloseRequested: root.close()
|
||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||
|
||||
Flickable {
|
||||
id: weatherScroll
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: weatherColumn.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
interactive: contentHeight > height
|
||||
|
||||
Column {
|
||||
id: weatherColumn
|
||||
width: weatherScroll.width
|
||||
spacing: Style.space(14)
|
||||
|
||||
// ---- Hero row: big icon + temp on the left; location and stats stacked on the right.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Math.max(heroLeft.height, heroRight.height)
|
||||
|
||||
Row {
|
||||
id: heroLeft
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(16)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(16)
|
||||
|
||||
Text {
|
||||
id: heroIcon
|
||||
textFormat: Text.PlainText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: 5
|
||||
text: root.label || "—"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
// Decorative condition emoji; intentionally larger than the
|
||||
// Style.font.* scale's displayLarge (28).
|
||||
font.pixelSize: 64
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(2)
|
||||
|
||||
Text {
|
||||
id: tempBig
|
||||
textFormat: Text.PlainText
|
||||
text: root.reportTempNum || "—"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
// Hero temperature read-out; deliberately oversized, outside
|
||||
// the Style.font.* scale.
|
||||
font.pixelSize: 56
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.current ? root.tempUnit : ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.display
|
||||
anchors.top: tempBig.top
|
||||
anchors.topMargin: Style.space(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: heroRight
|
||||
width: weatherStats.implicitWidth
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(20)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(12)
|
||||
|
||||
Row {
|
||||
visible: !root.editingLocation && root.reportLocation !== ""
|
||||
spacing: Style.space(6)
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.startEditingLocation()
|
||||
}
|
||||
HoverHandler {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "" // nf-fa-map_marker
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: (root.reportLocation || "").toUpperCase()
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
font.letterSpacing: 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
visible: root.editingLocation
|
||||
spacing: Style.space(6)
|
||||
|
||||
TextField {
|
||||
id: locationField
|
||||
width: Style.space(190)
|
||||
enabled: !root.savingLocation
|
||||
placeholderText: "Search city"
|
||||
foreground: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
|
||||
onTextChanged: if (root.editingLocation && !root.savingLocation) geocodeDebounce.restart()
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
root.cancelEditingLocation()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Down) {
|
||||
if (root.suggestionIndex < root.locationSuggestions.length - 1) root.suggestionIndex++
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up) {
|
||||
if (root.suggestionIndex > 0) root.suggestionIndex--
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
root.commitLocation()
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear back to IP auto-detect. While a committed location is
|
||||
// loading, this same compact affordance becomes a spinner.
|
||||
Rectangle {
|
||||
width: Style.space(18)
|
||||
height: Style.space(18)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: Math.min(4, Style.cornerRadius)
|
||||
color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.centerIn: parent
|
||||
text: root.savingLocation ? "" : "✕"
|
||||
font.family: root.bar.fontFamily
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
|
||||
RotationAnimator on rotation {
|
||||
running: root.savingLocation
|
||||
from: 0; to: 360
|
||||
duration: 800
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: clearLocationArea
|
||||
anchors.fill: parent
|
||||
enabled: !root.savingLocation
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.clearLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: weatherStats
|
||||
visible: !!root.current
|
||||
spacing: Style.space(36)
|
||||
|
||||
Column {
|
||||
spacing: Style.space(5)
|
||||
Text {
|
||||
text: "FEELS"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.reportFeels
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: Style.space(5)
|
||||
Text {
|
||||
text: "WIND"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.reportWind
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: Style.space(5)
|
||||
Text {
|
||||
text: "HUMID"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.reportHumidity
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Geocoding suggestions while the location is being edited.
|
||||
Column {
|
||||
visible: root.editingLocation && !root.savingLocation && root.locationSuggestions.length > 0
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.locationSuggestions
|
||||
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
height: suggestionRow.implicitHeight + Style.space(12)
|
||||
radius: Style.cornerRadius
|
||||
color: index === root.suggestionIndex ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
|
||||
|
||||
Row {
|
||||
id: suggestionRow
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(16)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(8)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: modelData.name
|
||||
color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: text !== ""
|
||||
text: modelData.description
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPositionChanged: root.suggestionIndex = index
|
||||
onClicked: root.pickSuggestion(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !root.current
|
||||
text: "Fetching forecast…"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.italic: true
|
||||
}
|
||||
|
||||
// ---- Divider between current conditions and forecast.
|
||||
Rectangle {
|
||||
visible: root.forecastDays.length > 0
|
||||
width: parent.width
|
||||
height: Style.spacing.hairline
|
||||
color: root.bar.foreground
|
||||
opacity: 0.12
|
||||
}
|
||||
|
||||
// ---- Forecast row: each cell has the day icon left of a day-name + hi/lo column.
|
||||
// Wrapped in an Item so the block of cells can be centered within the popup.
|
||||
Item {
|
||||
visible: root.forecastDays.length > 0
|
||||
width: parent.width
|
||||
height: forecastRow.height
|
||||
|
||||
Row {
|
||||
id: forecastRow
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Style.space(44)
|
||||
|
||||
Repeater {
|
||||
model: root.forecastDays
|
||||
|
||||
Row {
|
||||
required property var modelData
|
||||
required property int index
|
||||
spacing: Style.space(10)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.dayIcon(modelData)
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.display
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(2)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.dayName(modelData.date).toUpperCase()
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Style.space(6)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.bareTempForDay(modelData, "max")
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.bareTempForDay(modelData, "min")
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user