Project: Build a Weather Widget
The Problem
You have learned about processes, JSON parsing, HTTP requests, caching, and timers. Now it is time to combine them into a polished, production-ready component: a weather widget that fetches current conditions, displays them in a panel, and works offline.
A complete weather widget must:
- Fetch current temperature, condition, and icon from a weather API.
- Parse JSON responses safely with fallback defaults.
- Cache the last known data so it works offline.
- Update on a configurable interval (respecting rate limits).
- Display the data with appropriate icons and colors.
- Handle errors gracefully (network down, invalid API key, malformed response).
The Naive Approach
A single-file widget with hardcoded intervals, no caching, and no error handling:
Text {
property var data: ({})
Timer {
interval: 600000
running: true
repeat: true
onTriggered: {
var xhr = new XMLHttpRequest()
xhr.open("GET", "https://api.weatherapi.com/v1/current.json?key=KEY&q=London")
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) data = JSON.parse(xhr.responseText)
}
xhr.send()
}
}
// Crashes if JSON.parse fails or data is empty
}This crashes on network errors, shows nothing while loading, and cannot serve stale data offline.
A weather widget is like a newspaper stand. The API is the printing press (expensive, limited runs). The cache is your stack of newspapers (instant access, possibly stale). The display is the front page (always visible, always formatted). When the press stops (offline), you still sell yesterday's papers.
The Idea
Build a modular weather widget with three layers:
- Fetcher — makes HTTP requests at configurable intervals, emits parsed data.
- Cache — persists responses to disk via
TextFile, serves stale data when offline. - Display — renders temperature, condition, and icon from the current data, with loading and error states.
Let's Build It
// WeatherWidget.qml
import Quickshell
import QtQuick
import QtQuick.Layouts
Item {
id: root
// ── Configuration ──
property string apiKey: ""
property string location: "London"
property int refreshInterval: 600000 // 10 minutes
property string cacheFile: "~/.cache/quickshell/weather.json"
// ── State ──
property var rawData: ({})
property bool loading: true
property bool error: false
property string errorMessage: ""
// ── Parsed data ──
property int temperature: parseData()?.current?.temp_c ?? 0
property string condition: parseData()?.current?.condition?.text ?? "Unknown"
property string iconUrl: parseData()?.current?.condition?.icon ?? ""
property string locationName: parseData()?.location?.name ?? "Unknown"
property string locCountry: parseData()?.location?.country ?? ""
function parseData() {
try {
return rawData && typeof rawData === "object" ? rawData : null
} catch (e) {
return null
}
}
// ── Cache ──
TextFile {
id: cacheFileReader
path: root.cacheFile
onContentChanged: {
if (content.length > 0) {
try {
var cached = JSON.parse(content)
if (cached && cached.data) {
root.rawData = cached.data
root.loading = false
}
} catch (e) {
console.warn("Cache parse failed:", e)
}
}
}
}
// ── Fetcher ──
Process {
id: fetcher
property string url: ""
command: ["sh", "-c", "curl -s '" + url + "'"]
onStdout: (data) => {
try {
var parsed = JSON.parse(data)
if (parsed.error) {
root.error = true
root.errorMessage = parsed.error.message ?? "API error"
root.loading = false
return
}
root.rawData = parsed
root.error = false
root.loading = false
root.saveCache(parsed)
} catch (e) {
root.error = true
root.errorMessage = "Failed to parse response"
root.loading = false
}
}
onFinished: (code) => {
if (code !== 0 && root.loading) {
root.error = true
root.errorMessage = "Network error (code " + code + ")"
root.loading = false
}
}
}
function saveCache(data) {
var entry = JSON.stringify({ data: data, ts: Date.now() })
var escaped = "'" + entry.replace(/'/g, "'\\''") + "'"
var proc = processComponent.createObject(null, {
command: ["sh", "-c", "echo " + escaped + " > " + root.cacheFile]
})
}
function buildUrl() {
return "https://api.weatherapi.com/v1/current.json?key=" +
root.apiKey + "&q=" + encodeURIComponent(root.location)
}
function refresh() {
root.loading = true
root.error = false
fetcher.url = buildUrl()
fetcher.running = true
}
Component.onCompleted: refresh()
Timer {
interval: root.refreshInterval
running: true
repeat: true
onTriggered: root.refresh()
}
// ── Display ──
implicitWidth: displayRow.implicitWidth
implicitHeight: displayRow.implicitHeight
RowLayout {
id: displayRow
spacing: 6
visible: !root.loading
// Weather icon
Text {
text: conditionToIcon(root.condition)
font.pixelSize: 22
color: "#c0caf5"
}
ColumnLayout {
spacing: 0
Text {
text: root.temperature + "°C"
color: "#c0caf5"
font.pixelSize: 16
font.bold: true
}
Text {
text: root.condition
color: "#a9b1d6"
font.pixelSize: 11
visible: root.condition !== "Unknown"
}
}
}
// Loading indicator
Text {
anchors.centerIn: parent
text: "⏳"
font.pixelSize: 20
visible: root.loading && !root.error
}
// Error indicator
Text {
text: "⚠"
font.pixelSize: 20
color: "#f7768e"
visible: root.error
}
function conditionToIcon(cond) {
var c = cond.toLowerCase()
if (c.includes("sun") || c.includes("clear")) return "\u2600"
if (c.includes("cloud")) return "\u2601"
if (c.includes("rain") || c.includes("drizzle")) return "\u2614"
if (c.includes("snow") || c.includes("sleet")) return "\u2744"
if (c.includes("fog") || c.includes("mist") || c.includes("haze")) return "\u2601"
if (c.includes("thunder") || c.includes("storm")) return "\u26A1"
return "\u2601"
}
}Let's Improve It
Add Location Autodetection
Use a process call to determine the user's location via IP geolocation:
Process {
id: geoFetcher
command: ["sh", "-c", "curl -s 'https://ipapi.co/json/'"]
onStdout: (data) => {
try {
var geo = JSON.parse(data)
root.location = geo.city + "," + geo.country_code
root.refresh()
} catch (e) {
console.warn("Geo detection failed, using default location")
}
}
}Add Forecast Data
Fetch and display a 3-day forecast from the API:
Process {
id: forecastFetcher
command: ["sh", "-c", "curl -s 'https://api.weatherapi.com/v1/forecast.json?key=" +
root.apiKey + "&q=" + encodeURIComponent(root.location) + "&days=3'"]
onStdout: (data) => {
try {
var parsed = JSON.parse(data)
root.forecastData = parsed.forecast?.forecastday ?? []
} catch (e) {
console.warn("Forecast fetch failed")
}
}
}Keyboard-Accessible Refresh
Add a MouseArea and Keys handler so the widget can be refreshed manually:
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
onClicked: root.refresh()
Keys.onSpacePressed: root.refresh()
}Not validating API responses. APIs can return 200 OK with an error object inside. Always check for response.error field before parsing data.
Over-fetching on visibility change. Do not restart the refresh timer in onVisibleChanged unless the widget was hidden for longer than the refresh interval. Keep a lastRefreshTimestamp to decide.
Ignoring unicode fallbacks. Weather icon fonts are not universal. Use Nerd Font symbols for best results, but provide text fallbacks for systems without them. The condition text already serves as a fallback.
Under the Hood
The weather widget demonstrates a layered architecture pattern:
- Data layer (
Process,TextFile) — raw HTTP and file I/O. - Parsing layer (
JSON.parse,try/catch) — transforms raw text to structured objects. - State layer (
rawData,loading,error) — tracks widget lifecycle. - Presentation layer (bindings to temperature, condition, icons) — derived properties drive the UI.
This separation ensures each layer can be tested, replaced, or extended independently. For example, you could swap Process+curl for a native HttpRequest type without touching the display code.
The TextFile cache works because TextFile watches for file changes via inotify. When the fetch process writes the cache file, TextFile detects the change and emits onContentChanged, which updates the rawData property, which cascades through all dependent bindings. This reactive chain is the core pattern of Quickshell programming.
Exercises
- Combine Process, TextFile, Timer, and JSON.parse into a cohesive data pipeline
- Cache API responses locally for offline resilience
- Track loading and error states explicitly in the UI
- Use a layered architecture: data → parse → state → display
- Respect API rate limits with conservative refresh intervals