Feature: Added Weather widget
This commit is contained in:
242
dotfiles/.config/quickshell/config/WeatherData.qml
Normal file
242
dotfiles/.config/quickshell/config/WeatherData.qml
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
// Live weather for the bar's Weather.qml icon. A singleton for the same
|
||||||
|
// reason as SystemUpdates.qml: Bar.qml is instantiated once per monitor
|
||||||
|
// (shell.qml's Variants), so without this being shared, every extra screen
|
||||||
|
// would mean another set of network requests running in parallel.
|
||||||
|
//
|
||||||
|
// Location is auto-detected from the public IP (via ipwho.is, no API key)
|
||||||
|
// and cached to disk (~/.cache/quickshell/weather-location.json) so it's
|
||||||
|
// only ever looked up once, not on every quickshell restart — IP-geolocation
|
||||||
|
// free tiers rate-limit fast, and a desktop's location barely ever changes
|
||||||
|
// anyway. Only the weather itself re-polls on a timer. Conditions come from
|
||||||
|
// Open-Meteo (api.open-meteo.com) — also free and keyless, and it does its
|
||||||
|
// own °C/km-h conversion server-side via query params.
|
||||||
|
//
|
||||||
|
// Loosely modeled on omarchy's weather panel
|
||||||
|
// (github.com/omacom/omarchy, quattro branch,
|
||||||
|
// shell/plugins/panels/weather/Model.js) for the WMO weather-code
|
||||||
|
// grouping, but the icons are this bar's own Material Symbols glyphs
|
||||||
|
// rather than pulling in a separate icon font just for this.
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Location barely ever changes for a desktop machine, so it's fetched
|
||||||
|
// once (and cached); the weather itself is worth re-checking
|
||||||
|
// periodically.
|
||||||
|
readonly property int refreshIntervalMs: 15 * 60 * 1000
|
||||||
|
|
||||||
|
property bool locating: true
|
||||||
|
property bool loading: false
|
||||||
|
property bool hasLocation: false
|
||||||
|
property string locationName: ""
|
||||||
|
|
||||||
|
property real latitude: 0
|
||||||
|
property real longitude: 0
|
||||||
|
|
||||||
|
function saveLocation() {
|
||||||
|
_locationCache.setText(JSON.stringify({
|
||||||
|
latitude: root.latitude,
|
||||||
|
longitude: root.longitude,
|
||||||
|
name: root.locationName
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from Weather.qml's search box once you pick a result — always
|
||||||
|
// wins over whatever IP-based guess was cached before, and persists so
|
||||||
|
// it sticks across restarts.
|
||||||
|
function selectLocation(name, lat, lon) {
|
||||||
|
root.latitude = lat
|
||||||
|
root.longitude = lon
|
||||||
|
root.locationName = name
|
||||||
|
root.hasLocation = true
|
||||||
|
root.locating = false
|
||||||
|
root.saveLocation()
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
property bool searching: false
|
||||||
|
property var searchResults: []
|
||||||
|
|
||||||
|
function search(query) {
|
||||||
|
const q = String(query || "").trim()
|
||||||
|
if (q.length < 2) {
|
||||||
|
root.searchResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.searching = true
|
||||||
|
_searchProc.command = ["curl", "-s", "--max-time", "6",
|
||||||
|
"https://geocoding-api.open-meteo.com/v1/search?count=5&language=en&format=json&name=" + encodeURIComponent(q)]
|
||||||
|
_searchProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
property real tempC: 0
|
||||||
|
property real feelsLikeC: 0
|
||||||
|
property int humidity: 0
|
||||||
|
property real windKmh: 0
|
||||||
|
property int weatherCode: 0
|
||||||
|
property bool isDay: true
|
||||||
|
|
||||||
|
// [{date, code, maxC, minC}] — tomorrow onward; today is already
|
||||||
|
// covered by the current-conditions fields above.
|
||||||
|
property var forecast: []
|
||||||
|
|
||||||
|
function locate() {
|
||||||
|
if (_locateProc.running) return
|
||||||
|
root.locating = true
|
||||||
|
_locateProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!root.hasLocation || _weatherProc.running) return
|
||||||
|
root.loading = true
|
||||||
|
_weatherProc.command = ["curl", "-s", "--max-time", "8",
|
||||||
|
"https://api.open-meteo.com/v1/forecast?latitude=" + root.latitude
|
||||||
|
+ "&longitude=" + root.longitude
|
||||||
|
+ "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,is_day,wind_speed_10m"
|
||||||
|
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
|
||||||
|
+ "&temperature_unit=celsius&wind_speed_unit=kmh&timezone=auto&forecast_days=4"]
|
||||||
|
_weatherProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Material Symbols Rounded glyph for a WMO weather code (see
|
||||||
|
// https://open-meteo.com/en/docs#weathervariables), grouped the same
|
||||||
|
// way omarchy's Model.js groups them into its own icon set.
|
||||||
|
function iconFor(code, day) {
|
||||||
|
if (code === 0) return day ? "sunny" : "clear_night"
|
||||||
|
if (code === 1 || code === 2) return day ? "partly_cloudy_day" : "partly_cloudy_night"
|
||||||
|
if (code === 3) return "cloud"
|
||||||
|
if (code === 45 || code === 48) return "foggy"
|
||||||
|
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].indexOf(code) !== -1) return "rainy"
|
||||||
|
if ([71, 73, 75, 77, 85, 86].indexOf(code) !== -1) return "weather_snowy"
|
||||||
|
if (code === 95 || code === 96 || code === 99) return "thunderstorm"
|
||||||
|
return "cloud"
|
||||||
|
}
|
||||||
|
|
||||||
|
// QtObject has no default property, so these are attached via property
|
||||||
|
// assignment rather than as plain child elements (same as Colors.qml's
|
||||||
|
// FileView and SystemUpdates.qml's Process/Timer).
|
||||||
|
|
||||||
|
// Read once at startup; onLoaded/onLoadFailed (below) decide whether a
|
||||||
|
// network lookup is still needed.
|
||||||
|
readonly property FileView _locationCache: FileView {
|
||||||
|
path: Quickshell.env("HOME") + "/.cache/quickshell/weather-location.json"
|
||||||
|
onLoaded: {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text())
|
||||||
|
const lat = parseFloat(data.latitude)
|
||||||
|
const lon = parseFloat(data.longitude)
|
||||||
|
if (!isNaN(lat) && !isNaN(lon)) {
|
||||||
|
root.latitude = lat
|
||||||
|
root.longitude = lon
|
||||||
|
root.locationName = data.name || ""
|
||||||
|
root.hasLocation = true
|
||||||
|
root.locating = false
|
||||||
|
root.refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fall through to a fresh lookup below
|
||||||
|
}
|
||||||
|
root.locate()
|
||||||
|
}
|
||||||
|
onLoadFailed: root.locate()
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Process _locateProc: Process {
|
||||||
|
command: ["curl", "-s", "--max-time", "6", "https://ipwho.is/"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: locateCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(locateCollector.text)
|
||||||
|
const lat = parseFloat(data.latitude)
|
||||||
|
const lon = parseFloat(data.longitude)
|
||||||
|
root.hasLocation = data.success !== false && !isNaN(lat) && !isNaN(lon)
|
||||||
|
if (root.hasLocation) {
|
||||||
|
root.latitude = lat
|
||||||
|
root.longitude = lon
|
||||||
|
root.locationName = data.city || data.region || data.country || ""
|
||||||
|
root.saveLocation()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Weather: locate parse failed:", e)
|
||||||
|
root.hasLocation = false
|
||||||
|
}
|
||||||
|
root.locating = false
|
||||||
|
if (root.hasLocation) root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Process _weatherProc: Process {
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: weatherCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(weatherCollector.text)
|
||||||
|
const current = data.current || {}
|
||||||
|
root.tempC = current.temperature_2m
|
||||||
|
root.feelsLikeC = current.apparent_temperature
|
||||||
|
root.humidity = current.relative_humidity_2m
|
||||||
|
root.windKmh = current.wind_speed_10m
|
||||||
|
root.weatherCode = current.weather_code
|
||||||
|
root.isDay = current.is_day === 1
|
||||||
|
|
||||||
|
const daily = data.daily || {}
|
||||||
|
const times = daily.time || []
|
||||||
|
const days = []
|
||||||
|
// Start at 1 — index 0 is today, already shown as the
|
||||||
|
// current conditions above; this is "what's coming".
|
||||||
|
for (let i = 1; i < times.length && days.length < 3; i++) {
|
||||||
|
days.push({
|
||||||
|
date: times[i],
|
||||||
|
code: (daily.weather_code || [])[i],
|
||||||
|
maxC: (daily.temperature_2m_max || [])[i],
|
||||||
|
minC: (daily.temperature_2m_min || [])[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
root.forecast = days
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Weather: failed to parse Open-Meteo response:", e)
|
||||||
|
}
|
||||||
|
root.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Process _searchProc: Process {
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: searchCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(searchCollector.text)
|
||||||
|
const results = data.results || []
|
||||||
|
root.searchResults = results.filter(function(r) {
|
||||||
|
return r && r.name && r.latitude !== undefined && r.longitude !== undefined
|
||||||
|
}).map(function(r) {
|
||||||
|
return {
|
||||||
|
name: r.name,
|
||||||
|
description: [r.admin1, r.country].filter(function(p) { return !!p }).join(", "),
|
||||||
|
latitude: r.latitude,
|
||||||
|
longitude: r.longitude
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
root.searchResults = []
|
||||||
|
}
|
||||||
|
root.searching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Timer _timer: Timer {
|
||||||
|
interval: root.refreshIntervalMs
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ module config
|
|||||||
singleton Colors 1.0 Colors.qml
|
singleton Colors 1.0 Colors.qml
|
||||||
singleton Appearance 1.0 Appearance.qml
|
singleton Appearance 1.0 Appearance.qml
|
||||||
singleton VpnExclusivity 1.0 VpnExclusivity.qml
|
singleton VpnExclusivity 1.0 VpnExclusivity.qml
|
||||||
|
singleton SystemUpdates 1.0 SystemUpdates.qml
|
||||||
|
singleton WeatherData 1.0 WeatherData.qml
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ PanelWindow {
|
|||||||
ActiveWindow {}
|
ActiveWindow {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Clock {
|
RowLayout {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
spacing: Appearance.spacing
|
||||||
|
Weather { barWindow: bar }
|
||||||
|
Clock {}
|
||||||
|
SystemUpdate { barWindow: bar }
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
|
|||||||
413
dotfiles/.config/quickshell/modules/bar/Weather.qml
Normal file
413
dotfiles/.config/quickshell/modules/bar/Weather.qml
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
// Weather indicator — modeled on omarchy's weather bar pill
|
||||||
|
// (github.com/omacom/omarchy, quattro branch,
|
||||||
|
// shell/plugins/panels/weather) but rebuilt in this bar's own style: the
|
||||||
|
// actual location/polling logic lives in the WeatherData singleton (config/),
|
||||||
|
// shared across every monitor's bar; this is just the icon + panel UI,
|
||||||
|
// same split as SystemUpdate.qml.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: row.implicitWidth + 2
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
// The bar's own PanelWindow — same barWindow-anchoring convention as
|
||||||
|
// every other popup-having module here (Volume.qml, SystemUpdate.qml,
|
||||||
|
// etc.), so the panel opens in a fixed spot rather than following this
|
||||||
|
// icon's exact position in its RowLayout.
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
readonly property bool busy: WeatherData.locating || WeatherData.loading
|
||||||
|
|
||||||
|
// Local to this popup instance — unlike WeatherData's own state, there's
|
||||||
|
// no need to share an in-progress search across monitors' bars.
|
||||||
|
property bool searchOpen: false
|
||||||
|
property string searchQuery: ""
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: row
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 18
|
||||||
|
color: Colors.foreground
|
||||||
|
// Same spinner treatment as SystemUpdate.qml/
|
||||||
|
// BluetoothIndicator.qml's scan spinner while the location or
|
||||||
|
// conditions are still loading.
|
||||||
|
text: root.busy ? "progress_activity" : (WeatherData.hasLocation ? WeatherData.iconFor(WeatherData.weatherCode, WeatherData.isDay) : "cloud_off")
|
||||||
|
|
||||||
|
RotationAnimation on rotation {
|
||||||
|
running: root.busy
|
||||||
|
loops: Animation.Infinite
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 900
|
||||||
|
onRunningChanged: if (!running) label.rotation = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: WeatherData.hasLocation && !WeatherData.locating
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
text: Math.round(WeatherData.tempC) + "°"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: (mouse) => {
|
||||||
|
if (mouse.button === Qt.RightButton) {
|
||||||
|
WeatherData.refresh()
|
||||||
|
} else {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
// Same contentWidth/contentPadding split as the other panels
|
||||||
|
// (Volume.qml, SystemUpdate.qml, etc.) rather than a single magic
|
||||||
|
// implicitWidth.
|
||||||
|
readonly property int contentWidth: 220
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
// Unlike anchor.item, anchor.window doesn't default rect to the
|
||||||
|
// window's own bounds — set it explicitly so edges/gravity below
|
||||||
|
// resolve against the full bar. Bottom alone (no Left/Right)
|
||||||
|
// anchors to the bar's bottom-center, which lines up with this
|
||||||
|
// icon since it sits in the bar's centered RowLayout.
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom
|
||||||
|
gravity: Edges.Bottom
|
||||||
|
margins.bottom: -4
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprlandFocusGrab {
|
||||||
|
id: grab
|
||||||
|
windows: [panel]
|
||||||
|
onCleared: panel.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 3
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mainColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: panel.contentPadding
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: label.text
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 28
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
text: {
|
||||||
|
if (WeatherData.locating) return "Locating…"
|
||||||
|
if (!WeatherData.hasLocation) return "Location unavailable"
|
||||||
|
return WeatherData.locationName || "Weather"
|
||||||
|
}
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: WeatherData.hasLocation && !WeatherData.locating
|
||||||
|
text: WeatherData.loading ? "Updating…" : "Feels like " + Math.round(WeatherData.feelsLikeC) + "°"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: WeatherData.hasLocation && !WeatherData.locating
|
||||||
|
text: Math.round(WeatherData.tempC) + "°"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 28
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggles the search box below — same tap target size
|
||||||
|
// and hover feedback as the picker-target rows in
|
||||||
|
// VpnIndicator.qml.
|
||||||
|
Text {
|
||||||
|
text: root.searchOpen ? "close" : "edit_location"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: editLocationMouse.containsMouse ? Colors.primary : Colors.muted
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: editLocationMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -4
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
root.searchOpen = !root.searchOpen
|
||||||
|
root.searchQuery = ""
|
||||||
|
WeatherData.searchResults = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// City search — always available regardless of how the
|
||||||
|
// current location was resolved (IP guess or a previous
|
||||||
|
// search), so a wrong auto-detected city is one click away
|
||||||
|
// from being fixed. Same search-box + result-list shape as
|
||||||
|
// VpnIndicator.qml's Mullvad location picker.
|
||||||
|
Column {
|
||||||
|
visible: root.searchOpen
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: 24
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
visible: citySearchInput.text.length === 0
|
||||||
|
text: "Search city…"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: citySearchInput
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
color: Colors.foreground
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
clip: true
|
||||||
|
focus: root.searchOpen
|
||||||
|
text: root.searchQuery
|
||||||
|
onTextChanged: {
|
||||||
|
root.searchQuery = text
|
||||||
|
searchDebounce.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: searchDebounce
|
||||||
|
interval: 400
|
||||||
|
onTriggered: WeatherData.search(root.searchQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: WeatherData.searching
|
||||||
|
text: "Searching…"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: !WeatherData.searching && citySearchInput.text.length >= 2 && WeatherData.searchResults.length === 0
|
||||||
|
text: "No matches"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
width: parent.width
|
||||||
|
height: Math.min(resultsColumn.implicitHeight, 150)
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: resultsColumn.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: resultsColumn
|
||||||
|
width: parent.width
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: WeatherData.searchResults
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: resultText.implicitHeight + 8
|
||||||
|
radius: 0
|
||||||
|
color: resultMouse.containsMouse ? Colors.primary : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: resultText
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 4
|
||||||
|
elide: Text.ElideRight
|
||||||
|
text: modelData.description ? (modelData.name + " — " + modelData.description) : modelData.name
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: resultMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: resultMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
WeatherData.selectLocation(modelData.name, modelData.latitude, modelData.longitude)
|
||||||
|
root.searchOpen = false
|
||||||
|
root.searchQuery = ""
|
||||||
|
WeatherData.searchResults = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Humidity / wind — same two-stat layout, just laid out as
|
||||||
|
// a row of icon+value pairs rather than full sentences.
|
||||||
|
RowLayout {
|
||||||
|
visible: WeatherData.hasLocation && !WeatherData.locating
|
||||||
|
width: parent.width
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
text: "water_drop"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: WeatherData.humidity + "%"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
text: "air"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: Math.round(WeatherData.windKmh) + " km/h"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: WeatherData.forecast.length > 0
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
visible: WeatherData.forecast.length > 0
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: WeatherData.forecast
|
||||||
|
|
||||||
|
delegate: ColumnLayout {
|
||||||
|
required property var modelData
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: Qt.formatDateTime(new Date(modelData.date + "T12:00:00"), "ddd")
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: WeatherData.iconFor(modelData.code, true)
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 18
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: Math.round(modelData.maxC) + "°/" + Math.round(modelData.minC) + "°"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user