2 Commits

Author SHA1 Message Date
d395a155af Feature: Added Weather widget 2026-09-06 01:00:25 +01:00
243c90a809 Features: Added Update functionality 2026-09-06 00:35:20 +01:00
8 changed files with 1027 additions and 2 deletions

View File

@@ -0,0 +1,72 @@
pragma Singleton
import QtQuick
import Quickshell.Io
// Shared state for SystemUpdate.qml, the bar's package-update indicator.
// Bar.qml is instantiated once per screen (see shell.qml's Variants), so
// without this being a singleton, plugging in extra monitors would mean
// running `checkupdates` that many times in parallel on every poll — which
// isn't just wasteful, it's actively unreliable: concurrent invocations
// race over checkupdates' own sync directory and can intermittently report
// 0 pending updates. One shared Process here means exactly one `checkupdates`
// run at a time, and every bar's icon reflects the same result.
QtObject {
id: root
// checkupdates syncs a throwaway copy of the pacman db, so it's not
// free to run often — matches the old waybar custom/updater module's
// ~21.7 minute poll interval (see .config/waybar/config).
readonly property int refreshIntervalMs: 1300 * 1000
property int updateCount: 0
property bool checking: false
property bool updating: false
function refresh() {
if (_checkProc.running) return
root.checking = true
_checkProc.running = true
}
// scriptPath: absolute path to update-system.sh (resolved by the caller
// via Quickshell.shellPath, which isn't available as a plain import
// here without pulling in the whole Quickshell singleton for one call).
function runUpdate(scriptPath) {
if (_updateProc.running) return
root.updating = true
_updateProc.command = [scriptPath]
_updateProc.running = true
}
// QtObject has no default property, so these are attached via property
// assignment rather than as plain child elements (same as Colors.qml's
// FileView).
readonly property Process _checkProc: Process {
command: ["sh", "-c", "checkupdates 2>/dev/null | wc -l"]
stdout: StdioCollector {
id: checkCollector
onStreamFinished: {
root.updateCount = parseInt(checkCollector.text.trim(), 10) || 0
root.checking = false
}
}
}
// Runs update-system.sh, which opens its own terminal and blocks until
// that terminal is closed — so this only exits once the user's actually
// done updating, which is exactly when a re-check matters.
readonly property Process _updateProc: Process {
onExited: {
root.updating = false
root.refresh()
}
}
readonly property Timer _timer: Timer {
interval: root.refreshIntervalMs
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
}

View 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
+ "&current=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()
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -60,9 +60,13 @@ RowLayout {
IconImage { IconImage {
anchors.fill: parent anchors.fill: parent
// modelData.icon is a QUrl when it comes from
// Qt.resolvedUrl() (Bitwarden/Sunshine's local assets), not
// a QString — coerce to string before using String methods
// on it, or this throws instead of ever matching.
source: slot.running source: slot.running
? slot.trayItem.icon ? slot.trayItem.icon
: (slot.modelData.icon.includes("://") : (String(slot.modelData.icon).includes("://")
? slot.modelData.icon ? slot.modelData.icon
: Quickshell.iconPath(slot.modelData.icon)) : Quickshell.iconPath(slot.modelData.icon))
opacity: slot.running ? 1.0 : 0.5 opacity: slot.running ? 1.0 : 0.5

View File

@@ -0,0 +1,226 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import "../../config"
// Package update indicator — replaces the old waybar custom/updater module
// (see .config/waybar/config and its scripts/update-distro.sh). The actual
// polling/state lives in the SystemUpdates singleton (config/), shared
// across every monitor's bar; this is just the icon + panel UI. Clicking
// opens a panel showing the pending count with a button that runs the
// actual update in a terminal (scripts/update-system.sh, ported from the
// same waybar script, distro-detection and all).
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, PowerButton.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
RowLayout {
id: row
anchors.centerIn: parent
spacing: 4
Text {
id: label
// Nerd Font glyph (package-variant) normally, matching the old
// waybar custom/updater module's icon — not a Material Symbols
// ligature name like the rest of this bar, so it needs the Nerd
// Font instead of iconFontFamilyRounded. While a check is running
// (the periodic poll or a right-click-triggered refresh) this
// swaps to the same spinner glyph/animation as
// BluetoothIndicator.qml's scan spinner.
font.family: SystemUpdates.checking ? Appearance.iconFontFamilyRounded : Appearance.fontFamily
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: SystemUpdates.checking ? 15 : 22
color: SystemUpdates.updateCount > 0 ? Colors.primary : Colors.foreground
text: SystemUpdates.checking ? "progress_activity" : "󰏖"
RotationAnimation on rotation {
running: SystemUpdates.checking
loops: Animation.Infinite
from: 0
to: 360
duration: 900
// Otherwise the icon is left tilted at whatever angle the
// spin stopped on once checking finishes and it swaps back
// to the static glyph.
onRunningChanged: if (!running) label.rotation = 0
}
}
Text {
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: label.color
text: SystemUpdates.updateCount
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
SystemUpdates.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, PowerButton.qml, etc.) rather than a single magic
// implicitWidth.
readonly property int contentWidth: 200
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.fontFamily
font.pixelSize: 23
color: Colors.foreground
}
Text {
Layout.fillWidth: true
text: "System Update"
font.family: Appearance.fontFamily
font.pixelSize: 18
font.bold: true
color: Colors.foreground
}
}
Text {
width: parent.width
wrapMode: Text.WordWrap
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
color: Colors.foreground
text: {
if (SystemUpdates.updating) return "Update running in a terminal…"
if (SystemUpdates.checking) return "Checking for updates…"
if (SystemUpdates.updateCount === 0) return "System is up to date."
return SystemUpdates.updateCount + (SystemUpdates.updateCount === 1 ? " update available." : " updates available.")
}
}
// Same selectable-row shape as this project's other panel
// actions (e.g. PowerButton.qml's menu entries): radius 0,
// fixed-width icon column, fillWidth label, offset hover
// halo rather than one drawn on the row's own edge.
Rectangle {
id: updateBtn
width: parent.width
radius: 0
implicitHeight: btnContent.implicitHeight + 12
enabled: !SystemUpdates.updating
opacity: SystemUpdates.updating ? 0.5 : 1
color: btnMouse.containsMouse ? Colors.primary : "transparent"
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: btnMouse.containsMouse ? 1 : 0
}
RowLayout {
id: btnContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 6
spacing: 8
Text {
Layout.preferredWidth: 20
horizontalAlignment: Text.AlignLeft
text: "download"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: btnMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
Layout.fillWidth: true
text: "Update Now"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
color: btnMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
}
MouseArea {
id: btnMouse
anchors.fill: parent
enabled: !SystemUpdates.updating
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
SystemUpdates.runUpdate(Quickshell.shellPath("scripts/update-system.sh"))
panel.visible = false
grab.active = false
}
}
}
}
}
}
}

View 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
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Launched by SystemUpdate.qml (top bar, next to the clock) — opens a
# terminal running whatever update command fits this distro, then notifies
# once it's done. Ported from the old waybar custom/updater module
# (.config/waybar/scripts/update-distro.sh); kept multi-distro even though
# this machine is Arch, since these dotfiles aren't.
#
# The window it opens is floated + centered by a matching Hyprland rule
# (system-update-terminal, in .config/hypr/UserConfigs/WindowRules.conf),
# same treatment as Bitwarden's window, rather than tiling into whatever
# workspace you happened to be on.
iDIR="$HOME/.config/swaync/images"
if ! command -v foot &> /dev/null; then
notify-send -i "$iDIR/error.png" "Need foot:" "foot terminal not found. Please install foot terminal."
exit 1
fi
# Printed inside the terminal before the actual update command runs, so it
# reads as "updating" from the first frame rather than looking like an
# empty/stuck window while pacman/apt/etc. take a moment to start printing.
#
# foot runs this in a fresh `bash -c`, which doesn't inherit shell functions
# from this script's own environment (export -f only reaches bash processes
# forked from *this* one, not whatever foot execs) — so instead, the
# already-colored text is built once here with real escape bytes ($'...'),
# then %q-quoted into a self-contained `echo '...'` that reproduces those
# same bytes verbatim when the child bash parses it.
msg=" Updating System — please wait "
line=$(printf '=%.0s' $(seq 1 ${#msg}))
banner_text=$'\033[1;36m'"$line"$'\033[0m\n\033[1;36m'"$msg"$'\033[0m\n\033[1;36m'"$line"$'\033[0m\n\n'
BANNER="echo $(printf '%q' "$banner_text")"
if command -v paru &> /dev/null; then
CMD="paru -Syu"
LABEL="Arch-based system"
elif command -v yay &> /dev/null; then
CMD="yay -Syu"
LABEL="Arch-based system"
elif command -v dnf &> /dev/null; then
CMD="sudo dnf update --refresh -y"
LABEL="Fedora system"
elif command -v apt &> /dev/null; then
CMD="sudo apt update && sudo apt upgrade -y"
LABEL="Debian/Ubuntu system"
elif command -v zypper &> /dev/null; then
CMD="sudo zypper dup -y"
LABEL="openSUSE system"
else
notify-send -i "$iDIR/error.png" -u critical "Unsupported system" "This script does not support your distribution."
exit 1
fi
# Keeps the terminal open (whether the update succeeded or failed — read
# always runs regardless, since it's ; not &&) until you press Enter,
# instead of foot closing the instant the update command exits and taking
# any output/errors with it.
WAIT_PROMPT="echo; read -p 'Update finished — press Enter to close...'"
foot -T update bash -c "$BANNER; $CMD; $WAIT_PROMPT"
notify-send -i "$iDIR/profile-pic.jpg" -u low "$LABEL" 'has been updated.'