Important information: Google announced that, from September 2026, Android devices will require ALL apps to be signed by Google, effectively leading to an iOS situation. Value your right to a computer that does what you want; do not tolerate this monopolistic practice! Contact me if you don't understand why it is bad. Click to learn more.

 main.py

View raw Download
text/plain • 9.6 kiB
Python script, Unicode text, UTF-8 text executable
        
            
1
"""
2
Panorama weather: weather app for GNU/Linux desktops.
3
Copyright 2026, roundabout-host.com <vlad@roundabout-host.com>
4
5
This program is free software: you can redistribute it and/or modify
6
it under the terms of the GNU General Public Licence as published by
7
the Free Software Foundation, either version 3 of the Licence, or
8
(at your option) any later version.
9
10
This program is distributed in the hope that it will be useful,
11
but WITHOUT ANY WARRANTY; without even the implied warranty of
12
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
GNU General Public Licence for more details.
14
15
You should have received a copy of the GNU General Public Licence
16
along with this program. If not, see <https://www.gnu.org/licenses/>.
17
"""
18
import itertools
19
import locale
20
import os
21
import sys
22
import json
23
24
import gi
25
26
from pathlib import Path
27
28
gi.require_version("Gtk", "4.0")
29
30
from gi.repository import Gtk, Gio, GLib, Gdk
31
32
33
module_directory = Path(__file__).resolve().parent
34
locale.bindtextdomain("panorama-weather", str(module_directory / "locale"))
35
locale.textdomain("locale")
36
locale.setlocale(locale.LC_ALL)
37
_ = locale.gettext
38
39
40
custom_css = """
41
.panorama-weather-current-temperature {
42
font-weight: 100;
43
font-size: 5rem;
44
}
45
46
.panorama-weather-table-header {
47
font-weight: 600;
48
}
49
50
.panorama-weather-table-temperature {
51
font-size: 1.125em;
52
}
53
"""
54
css_provider = Gtk.CssProvider()
55
css_provider.load_from_data(custom_css)
56
Gtk.StyleContext.add_provider_for_display(
57
Gdk.Display.get_default(),
58
css_provider,
59
100
60
)
61
62
63
def relative_timestamp(datetime: GLib.DateTime) -> str:
64
now = GLib.DateTime.new_now_local()
65
seconds = int(now.difference(datetime) / GLib.TIME_SPAN_SECOND)
66
67
if seconds < 30:
68
return _("just now")
69
if seconds < 240:
70
return _("{time} seconds ago").format(time=seconds)
71
if seconds < 7200:
72
return _("{time} minutes ago").format(time=seconds//60)
73
if seconds < 345600:
74
return _("{time} hours ago").format(time=seconds//3600)
75
return _("{time} days ago").format(time=seconds//86400)
76
77
78
@Gtk.Template(filename=str(module_directory / "panorama-weather-report.ui"))
79
class WeatherReport(Gtk.Box):
80
__gtype_name__ = "WeatherReport"
81
82
current_details: Gtk.Label = Gtk.Template.Child()
83
current_temperature: Gtk.Label = Gtk.Template.Child()
84
current_weather_icon: Gtk.Image = Gtk.Template.Child()
85
forecast_box: Gtk.ListBox = Gtk.Template.Child()
86
attribution_label: Gtk.Label = Gtk.Template.Child()
87
88
def __init__(self, data, **kwargs):
89
super().__init__(**kwargs)
90
91
self.data = data
92
self.current_weather_icon.set_from_icon_name(data["icon_name"])
93
self.current_temperature.set_text(_("{temp} °{temp_unit}").format(
94
temp=locale.format_string("%.0f", data["temp_value"]),
95
temp_unit=data["units"]["temp"]
96
))
97
self.current_details.set_text(_("{prec} {prec_unit} in the last hour").format(
98
prec=locale.format_string("%.2f", data["prec"]),
99
prec_unit=data["units"]["prec"]
100
))
101
self.update_time = GLib.DateTime.new_from_unix_utc(data["time"]).to_local()
102
self.attribution_label.set_text(_("Retrieved {timestamp}. Source: {attribution}.").format(
103
timestamp=relative_timestamp(self.update_time),
104
attribution=data["attribution"]
105
))
106
107
self.time_size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL)
108
self.icon_size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL)
109
self.temperature_size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL)
110
111
self.header_box = Gtk.Box(spacing=4)
112
self.time_header = Gtk.Label.new(_("Time"))
113
self.time_header.add_css_class("panorama-weather-table-header")
114
self.time_size_group.add_widget(self.time_header)
115
self.condition_header = Gtk.Box()
116
self.icon_size_group.add_widget(self.condition_header)
117
self.temperature_header = Gtk.Label.new(_("Temperature"))
118
self.temperature_header.add_css_class("panorama-weather-table-header")
119
self.temperature_size_group.add_widget(self.temperature_header)
120
self.header_box.append(self.time_header)
121
self.header_box.append(Gtk.Box(hexpand=True)) # some empty space
122
self.header_box.append(self.temperature_header)
123
self.header_box.append(self.condition_header)
124
self.table_header = Gtk.ListBoxRow(child=self.header_box, activatable=False)
125
self.update_forecast_table()
126
127
def update_forecast_table(self):
128
self.forecast_box.remove_all()
129
self.forecast_box.append(self.table_header)
130
131
for point in self.data["forecast"]:
132
box = Gtk.Box(spacing=4)
133
134
forecasted_time = GLib.DateTime.new_from_unix_utc(point["time"]).to_local()
135
time_label = Gtk.Label.new(forecasted_time.format("%H"))
136
time_label.set_xalign(0)
137
self.time_size_group.add_widget(time_label)
138
box.append(time_label)
139
140
box.append(Gtk.Box(hexpand=True)) # some empty space
141
142
temperature_label = Gtk.Label.new(_("{temp} °{temp_unit}").format(
143
temp=locale.format_string("%.1f", point["temp_value"]),
144
temp_unit=self.data["units"]["temp"]
145
))
146
temperature_label.set_xalign(1)
147
temperature_label.add_css_class("panorama-weather-table-temperature")
148
self.temperature_size_group.add_widget(temperature_label)
149
box.append(temperature_label)
150
151
icon = Gtk.Image.new_from_gicon(Gio.ThemedIcon.new_with_default_fallbacks(point["icon_name"]))
152
icon.set_icon_size(Gtk.IconSize.LARGE)
153
icon.set_halign(Gtk.Align.END)
154
self.icon_size_group.add_widget(icon)
155
box.append(icon)
156
157
row = Gtk.ListBoxRow(child=box)
158
row.day = GLib.DateTime.new_local(forecasted_time.get_year(), forecasted_time.get_month(), forecasted_time.get_day_of_month(), 0, 0, 0)
159
self.forecast_box.append(row)
160
161
self.forecast_box.set_header_func(self.make_day_headers)
162
163
def make_day_headers(self, b: Gtk.ListBoxRow, a: Gtk.ListBoxRow):
164
if a is not None and hasattr(b, "day") and ((hasattr(a, "day") and not a.day.equal(b.day)) or not hasattr(a, "day")):
165
b.set_header(Gtk.Label.new(b.day.format("%A, %-d %B")))
166
else:
167
b.set_header(None)
168
169
170
@Gtk.Template(filename=str(module_directory / "panorama-weather.ui"))
171
class WeatherWindow(Gtk.Window):
172
__gtype_name__ = "WeatherWindow"
173
174
sidebar_revealer: Gtk.Revealer = Gtk.Template.Child()
175
sidebar: Gtk.Box = Gtk.Template.Child()
176
inner_sidebar: Gtk.Box = Gtk.Template.Child()
177
drag_handle: Gtk.Separator = Gtk.Template.Child()
178
weather_stack: Gtk.Stack = Gtk.Template.Child()
179
180
def sidebar_revealer_change(self, action, state):
181
action.set_state(state)
182
self.sidebar_revealer.set_reveal_child(state.get_boolean())
183
184
def begin_drag(self, gesture, start_x, start_y):
185
x_win, y_win = self.drag_handle.translate_coordinates(self, start_x, start_y)
186
gesture.widget_to_update.start_w = gesture.widget_to_update.get_width()
187
gesture.widget_to_update.start_x = x_win
188
gesture.start_x = start_x
189
190
def update_drag(self, gesture, dx, dy):
191
x_win, y_win = self.drag_handle.translate_coordinates(self, dx + gesture.start_x, 0)
192
gesture.widget_to_update.set_size_request(gesture.widget_to_update.start_w + x_win - gesture.widget_to_update.start_x, -1)
193
194
def set_window_title(self, stack, *args):
195
self.set_title(stack.get_page(stack.get_visible_child()).get_title())
196
197
def __init__(self, **kwargs):
198
super().__init__(**kwargs)
199
self.sidebar_revealer.set_hexpand(False)
200
self.weather_stack.set_hexpand(True)
201
self.weather_stack.set_halign(Gtk.Align.FILL)
202
self.weather_stack.connect("notify::visible-child", self.set_window_title)
203
204
self.action_group = Gio.SimpleActionGroup()
205
self.sidebar_action = Gio.SimpleAction.new_stateful("reveal-sidebar", None, GLib.Variant.new_boolean(True))
206
self.sidebar_action.connect("change-state", self.sidebar_revealer_change)
207
self.action_group.add_action(self.sidebar_action)
208
self.insert_action_group("win", self.action_group)
209
210
drag_controller = Gtk.GestureDrag()
211
drag_controller.widget_to_update = self.sidebar
212
drag_controller.connect("drag-begin", self.begin_drag)
213
drag_controller.connect("drag-update", self.update_drag)
214
self.drag_handle.add_controller(drag_controller)
215
self.drag_handle.set_cursor(Gdk.Cursor.new_from_name("ew-resize"))
216
217
def read_all(self):
218
application: PanoramaWeather = self.get_application()
219
# Read the weather files
220
for file in application.data_directory.iterdir():
221
with open(file, "r") as f:
222
data = json.load(f)
223
self.weather_stack.add_titled(WeatherReport(data), None, data["location"]["name"])
224
225
226
class PanoramaWeather(Gtk.Application):
227
def __init__(self):
228
super().__init__(
229
application_id="com.roundabout_host.roundabout.PanoramaWeather"
230
)
231
self.data_directory = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "weather" / "data" / "locations"
232
233
def do_startup(self):
234
Gtk.Application.do_startup(self)
235
236
def do_shutdown(self):
237
Gtk.Application.do_shutdown(self)
238
239
def do_activate(self):
240
Gtk.Application.do_activate(self)
241
242
window = WeatherWindow()
243
self.add_window(window)
244
window.present()
245
window.read_all()
246
247
248
if __name__ == "__main__":
249
app = PanoramaWeather()
250
app.run(sys.argv)
251