wevva 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wevva/__init__.py +50 -0
- wevva/__main__.py +11 -0
- wevva/api.py +281 -0
- wevva/app.py +186 -0
- wevva/cli.py +654 -0
- wevva/conditions.py +54 -0
- wevva/config.py +330 -0
- wevva/constants.py +34 -0
- wevva/controller.py +113 -0
- wevva/location_metadata.py +36 -0
- wevva/messages.py +70 -0
- wevva/models.py +24 -0
- wevva/openmeteo.py +696 -0
- wevva/screens/air_quality_help.py +117 -0
- wevva/screens/author_screen.py +83 -0
- wevva/screens/help.py +89 -0
- wevva/screens/search_screen.py +54 -0
- wevva/screens/settings_screen.py +143 -0
- wevva/screens/weather_screen.py +280 -0
- wevva/services/air_quality.py +39 -0
- wevva/services/geocoding.py +89 -0
- wevva/services/weather.py +42 -0
- wevva/utils/__init__.py +37 -0
- wevva/utils/colors.py +259 -0
- wevva/utils/formatting.py +90 -0
- wevva/utils/geo.py +49 -0
- wevva/utils/visualization.py +61 -0
- wevva/wevva.tcss +19 -0
- wevva/widgets/air_quality.py +212 -0
- wevva/widgets/astronomy_info.py +240 -0
- wevva/widgets/context_bar.py +121 -0
- wevva/widgets/current_conditions.py +147 -0
- wevva/widgets/current_detail.py +137 -0
- wevva/widgets/daily_forecast.py +82 -0
- wevva/widgets/daily_summary.py +192 -0
- wevva/widgets/hourly_forecast.py +421 -0
- wevva/widgets/location_info.py +234 -0
- wevva/widgets/precip_info.py +96 -0
- wevva/widgets/search_dialog.py +173 -0
- wevva/widgets/search_results.py +122 -0
- wevva/widgets/weather_summary.py +168 -0
- wevva/widgets/weather_widget.py +142 -0
- wevva-0.1.0.dist-info/METADATA +194 -0
- wevva-0.1.0.dist-info/RECORD +47 -0
- wevva-0.1.0.dist-info/WHEEL +4 -0
- wevva-0.1.0.dist-info/entry_points.txt +2 -0
- wevva-0.1.0.dist-info/licenses/LICENSE +21 -0
wevva/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Wevva package."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from wevva.api import (
|
|
6
|
+
LocationNotFoundError,
|
|
7
|
+
WevvaAPIError,
|
|
8
|
+
forecast_by_coordinates,
|
|
9
|
+
forecast_by_coordinates_sync,
|
|
10
|
+
forecast_by_place,
|
|
11
|
+
forecast_by_place_sync,
|
|
12
|
+
geocode,
|
|
13
|
+
geocode_sync,
|
|
14
|
+
)
|
|
15
|
+
from wevva.location_metadata import LocationMetadata
|
|
16
|
+
from wevva.models import ForecastBundle
|
|
17
|
+
from wevva.openmeteo import (
|
|
18
|
+
CurrentOpenMeteoForecast,
|
|
19
|
+
DailyOpenMeteoForecast,
|
|
20
|
+
HourlyOpenMeteoForecast,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
CurrentForecast = CurrentOpenMeteoForecast
|
|
24
|
+
HourlyForecast = HourlyOpenMeteoForecast
|
|
25
|
+
DailyForecast = DailyOpenMeteoForecast
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CurrentForecast",
|
|
29
|
+
"CurrentOpenMeteoForecast",
|
|
30
|
+
"DailyForecast",
|
|
31
|
+
"DailyOpenMeteoForecast",
|
|
32
|
+
"ForecastBundle",
|
|
33
|
+
"HourlyForecast",
|
|
34
|
+
"HourlyOpenMeteoForecast",
|
|
35
|
+
"LocationMetadata",
|
|
36
|
+
"LocationNotFoundError",
|
|
37
|
+
"WevvaAPIError",
|
|
38
|
+
"__version__",
|
|
39
|
+
"forecast_by_coordinates",
|
|
40
|
+
"forecast_by_coordinates_sync",
|
|
41
|
+
"forecast_by_place",
|
|
42
|
+
"forecast_by_place_sync",
|
|
43
|
+
"geocode",
|
|
44
|
+
"geocode_sync",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
__version__ = version("wevva")
|
|
49
|
+
except PackageNotFoundError:
|
|
50
|
+
__version__ = "0.0.0"
|
wevva/__main__.py
ADDED
wevva/api.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Public library API for fetching weather without the TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from typing import Any, Awaitable, TypeVar
|
|
8
|
+
|
|
9
|
+
from wevva.location_metadata import LocationMetadata
|
|
10
|
+
from wevva.models import ForecastBundle
|
|
11
|
+
from wevva.openmeteo import (
|
|
12
|
+
CurrentOpenMeteoForecast,
|
|
13
|
+
DailyOpenMeteoForecast,
|
|
14
|
+
HourlyOpenMeteoForecast,
|
|
15
|
+
OpenMeteoForecast,
|
|
16
|
+
)
|
|
17
|
+
from wevva.services.air_quality import fetch_air_quality
|
|
18
|
+
from wevva.services.geocoding import search_places
|
|
19
|
+
from wevva.services.weather import fetch_weather
|
|
20
|
+
|
|
21
|
+
AIR_QUALITY_FIELDS: tuple[str, ...] = (
|
|
22
|
+
"us_aqi",
|
|
23
|
+
"european_aqi",
|
|
24
|
+
"pm2_5",
|
|
25
|
+
"pm10",
|
|
26
|
+
"ozone",
|
|
27
|
+
"grass_pollen",
|
|
28
|
+
)
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WevvaAPIError(RuntimeError):
|
|
33
|
+
"""Base exception for public API helpers."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LocationNotFoundError(WevvaAPIError):
|
|
37
|
+
"""Raised when a place query returns no geocoding matches."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_sync(coro: Awaitable[T]) -> T:
|
|
41
|
+
"""Run an async coroutine from synchronous code."""
|
|
42
|
+
try:
|
|
43
|
+
asyncio.get_running_loop()
|
|
44
|
+
except RuntimeError:
|
|
45
|
+
return asyncio.run(coro)
|
|
46
|
+
raise WevvaAPIError(
|
|
47
|
+
"Sync API cannot run inside an active event loop. Use async API functions instead."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _location_metadata_from_place(place: dict[str, Any]) -> LocationMetadata:
|
|
52
|
+
"""Convert one geocoding result into ``LocationMetadata``."""
|
|
53
|
+
lat = place.get("latitude")
|
|
54
|
+
lon = place.get("longitude")
|
|
55
|
+
return LocationMetadata(
|
|
56
|
+
latitude=float(lat) if isinstance(lat, (int, float)) else None,
|
|
57
|
+
longitude=float(lon) if isinstance(lon, (int, float)) else None,
|
|
58
|
+
name=place.get("name") or "",
|
|
59
|
+
admin=place.get("admin") or "",
|
|
60
|
+
country=place.get("country") or "",
|
|
61
|
+
country_code=place.get("country_code") or "",
|
|
62
|
+
timezone=place.get("tz_identifier") or "",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _apply_place_metadata(
|
|
67
|
+
metadata: LocationMetadata, place: dict[str, Any] | None
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Overlay geocoding place fields onto API metadata."""
|
|
70
|
+
if not place:
|
|
71
|
+
return
|
|
72
|
+
metadata.name = place.get("name") or metadata.name
|
|
73
|
+
metadata.admin = place.get("admin") or metadata.admin
|
|
74
|
+
metadata.country = place.get("country") or metadata.country
|
|
75
|
+
metadata.country_code = place.get("country_code") or metadata.country_code
|
|
76
|
+
if not metadata.timezone:
|
|
77
|
+
metadata.timezone = place.get("tz_identifier") or metadata.timezone
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _merge_air_quality_fields(
|
|
81
|
+
hourly_data: dict[str, Any], air_quality: dict[str, Any] | None
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""Attach air-quality lists to hourly weather data."""
|
|
84
|
+
merged = dict(hourly_data)
|
|
85
|
+
if not air_quality:
|
|
86
|
+
return merged
|
|
87
|
+
aq_hourly = air_quality.get("hourly")
|
|
88
|
+
if not isinstance(aq_hourly, dict):
|
|
89
|
+
return merged
|
|
90
|
+
|
|
91
|
+
weather_times = merged.get("time", [])
|
|
92
|
+
weather_count = len(weather_times) if isinstance(weather_times, list) else 0
|
|
93
|
+
for field in AIR_QUALITY_FIELDS:
|
|
94
|
+
values = aq_hourly.get(field)
|
|
95
|
+
if not isinstance(values, list):
|
|
96
|
+
continue
|
|
97
|
+
if len(values) < weather_count:
|
|
98
|
+
values = values + [None] * (weather_count - len(values))
|
|
99
|
+
elif len(values) > weather_count:
|
|
100
|
+
values = values[:weather_count]
|
|
101
|
+
merged[field] = values
|
|
102
|
+
return merged
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _build_forecast_bundle(
|
|
106
|
+
weather_data: dict[str, Any],
|
|
107
|
+
*,
|
|
108
|
+
country_code: str = "",
|
|
109
|
+
place: dict[str, Any] | None = None,
|
|
110
|
+
) -> ForecastBundle:
|
|
111
|
+
"""Build typed forecast models from raw API weather data."""
|
|
112
|
+
data = deepcopy(weather_data)
|
|
113
|
+
metadata = OpenMeteoForecast.extract_metadata(data)
|
|
114
|
+
_apply_place_metadata(metadata, place)
|
|
115
|
+
|
|
116
|
+
units_current = OpenMeteoForecast.extract_units(data, key="current")
|
|
117
|
+
units_hourly = OpenMeteoForecast.extract_units(data, key="hourly")
|
|
118
|
+
units_daily = OpenMeteoForecast.extract_units(data, key="daily")
|
|
119
|
+
|
|
120
|
+
hourly_data = data.get("hourly") if isinstance(data.get("hourly"), dict) else {}
|
|
121
|
+
times = hourly_data.get("time", []) if isinstance(hourly_data, dict) else []
|
|
122
|
+
start = times[0] if times else None
|
|
123
|
+
end = times[-1] if times else None
|
|
124
|
+
|
|
125
|
+
air_quality = None
|
|
126
|
+
if (
|
|
127
|
+
start
|
|
128
|
+
and end
|
|
129
|
+
and metadata.latitude is not None
|
|
130
|
+
and metadata.longitude is not None
|
|
131
|
+
):
|
|
132
|
+
air_quality = await fetch_air_quality(
|
|
133
|
+
metadata.latitude,
|
|
134
|
+
metadata.longitude,
|
|
135
|
+
start,
|
|
136
|
+
end,
|
|
137
|
+
country_code or metadata.country_code,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
merged_hourly = _merge_air_quality_fields(hourly_data, air_quality)
|
|
141
|
+
data["hourly"] = merged_hourly
|
|
142
|
+
|
|
143
|
+
current = CurrentOpenMeteoForecast(metadata, units_current, data.get("current", {}))
|
|
144
|
+
hourly = HourlyOpenMeteoForecast(metadata, units_hourly, merged_hourly)
|
|
145
|
+
daily = DailyOpenMeteoForecast(metadata, units_daily, data.get("daily", {}))
|
|
146
|
+
|
|
147
|
+
return ForecastBundle(
|
|
148
|
+
metadata=metadata,
|
|
149
|
+
current=current,
|
|
150
|
+
hourly=hourly,
|
|
151
|
+
daily=daily,
|
|
152
|
+
raw=data,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def geocode(
|
|
157
|
+
query: str,
|
|
158
|
+
*,
|
|
159
|
+
count: int = 5,
|
|
160
|
+
language: str = "en",
|
|
161
|
+
) -> list[LocationMetadata]:
|
|
162
|
+
"""Search for places and return normalized location metadata entries."""
|
|
163
|
+
places = await search_places(query, count=count, language=language)
|
|
164
|
+
return [_location_metadata_from_place(place) for place in places]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def geocode_sync(
|
|
168
|
+
query: str,
|
|
169
|
+
*,
|
|
170
|
+
count: int = 5,
|
|
171
|
+
language: str = "en",
|
|
172
|
+
) -> list[LocationMetadata]:
|
|
173
|
+
"""Synchronous wrapper for :func:`geocode`."""
|
|
174
|
+
return _run_sync(geocode(query, count=count, language=language))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def forecast_by_coordinates(
|
|
178
|
+
*,
|
|
179
|
+
lat: float,
|
|
180
|
+
lon: float,
|
|
181
|
+
temperature_unit: str = "celsius",
|
|
182
|
+
wind_speed_unit: str = "kmh",
|
|
183
|
+
precipitation_unit: str = "mm",
|
|
184
|
+
country_code: str = "",
|
|
185
|
+
) -> ForecastBundle:
|
|
186
|
+
"""Fetch forecasts for explicit coordinates."""
|
|
187
|
+
weather_data = await fetch_weather(
|
|
188
|
+
lat=lat,
|
|
189
|
+
lon=lon,
|
|
190
|
+
temperature_unit=temperature_unit,
|
|
191
|
+
wind_speed_unit=wind_speed_unit,
|
|
192
|
+
precipitation_unit=precipitation_unit,
|
|
193
|
+
)
|
|
194
|
+
return await _build_forecast_bundle(weather_data, country_code=country_code)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def forecast_by_coordinates_sync(
|
|
198
|
+
*,
|
|
199
|
+
lat: float,
|
|
200
|
+
lon: float,
|
|
201
|
+
temperature_unit: str = "celsius",
|
|
202
|
+
wind_speed_unit: str = "kmh",
|
|
203
|
+
precipitation_unit: str = "mm",
|
|
204
|
+
country_code: str = "",
|
|
205
|
+
) -> ForecastBundle:
|
|
206
|
+
"""Synchronous wrapper for :func:`forecast_by_coordinates`."""
|
|
207
|
+
return _run_sync(
|
|
208
|
+
forecast_by_coordinates(
|
|
209
|
+
lat=lat,
|
|
210
|
+
lon=lon,
|
|
211
|
+
temperature_unit=temperature_unit,
|
|
212
|
+
wind_speed_unit=wind_speed_unit,
|
|
213
|
+
precipitation_unit=precipitation_unit,
|
|
214
|
+
country_code=country_code,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def forecast_by_place(
|
|
220
|
+
query: str,
|
|
221
|
+
*,
|
|
222
|
+
language: str = "en",
|
|
223
|
+
temperature_unit: str = "celsius",
|
|
224
|
+
wind_speed_unit: str = "kmh",
|
|
225
|
+
precipitation_unit: str = "mm",
|
|
226
|
+
) -> ForecastBundle:
|
|
227
|
+
"""Geocode a place query and fetch forecasts for the best match."""
|
|
228
|
+
matches = await search_places(query, count=1, language=language)
|
|
229
|
+
if not matches:
|
|
230
|
+
raise LocationNotFoundError(f"No location found for query: {query!r}")
|
|
231
|
+
|
|
232
|
+
place = matches[0]
|
|
233
|
+
lat = place.get("latitude")
|
|
234
|
+
lon = place.get("longitude")
|
|
235
|
+
if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)):
|
|
236
|
+
raise WevvaAPIError(f"Geocoding result for {query!r} is missing coordinates.")
|
|
237
|
+
|
|
238
|
+
weather_data = await fetch_weather(
|
|
239
|
+
lat=float(lat),
|
|
240
|
+
lon=float(lon),
|
|
241
|
+
temperature_unit=temperature_unit,
|
|
242
|
+
wind_speed_unit=wind_speed_unit,
|
|
243
|
+
precipitation_unit=precipitation_unit,
|
|
244
|
+
)
|
|
245
|
+
return await _build_forecast_bundle(
|
|
246
|
+
weather_data,
|
|
247
|
+
country_code=place.get("country_code") or "",
|
|
248
|
+
place=place,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def forecast_by_place_sync(
|
|
253
|
+
query: str,
|
|
254
|
+
*,
|
|
255
|
+
language: str = "en",
|
|
256
|
+
temperature_unit: str = "celsius",
|
|
257
|
+
wind_speed_unit: str = "kmh",
|
|
258
|
+
precipitation_unit: str = "mm",
|
|
259
|
+
) -> ForecastBundle:
|
|
260
|
+
"""Synchronous wrapper for :func:`forecast_by_place`."""
|
|
261
|
+
return _run_sync(
|
|
262
|
+
forecast_by_place(
|
|
263
|
+
query,
|
|
264
|
+
language=language,
|
|
265
|
+
temperature_unit=temperature_unit,
|
|
266
|
+
wind_speed_unit=wind_speed_unit,
|
|
267
|
+
precipitation_unit=precipitation_unit,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
__all__ = [
|
|
273
|
+
"LocationNotFoundError",
|
|
274
|
+
"WevvaAPIError",
|
|
275
|
+
"forecast_by_coordinates",
|
|
276
|
+
"forecast_by_coordinates_sync",
|
|
277
|
+
"forecast_by_place",
|
|
278
|
+
"forecast_by_place_sync",
|
|
279
|
+
"geocode",
|
|
280
|
+
"geocode_sync",
|
|
281
|
+
]
|
wevva/app.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Textual TUI for displaying weather forecasts and warnings (mini version).
|
|
2
|
+
|
|
3
|
+
Public-facing code should be easy to scan; this file adds concise
|
|
4
|
+
comments to highlight major blocks, actions, and message handlers.
|
|
5
|
+
Behavior remains unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
from textual.app import App
|
|
11
|
+
|
|
12
|
+
from wevva.controller import WeatherController # central async orchestrator
|
|
13
|
+
from wevva.location_metadata import LocationMetadata
|
|
14
|
+
from wevva.messages import PlaceSelected, WeatherFetchFailed, WeatherUpdated
|
|
15
|
+
from wevva.screens.help import HelpScreen
|
|
16
|
+
from wevva.screens.search_screen import SearchScreen
|
|
17
|
+
from wevva.screens.settings_screen import SettingsScreen
|
|
18
|
+
from wevva.screens.weather_screen import WeatherScreen
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Wevva(App, inherit_bindings=False):
|
|
22
|
+
"""Minimal textual weather app showing current, next 24h, daily and warnings.
|
|
23
|
+
|
|
24
|
+
- Message-first architecture: widgets react to `WeatherUpdated`.
|
|
25
|
+
- Compose once, then update in place for new data.
|
|
26
|
+
- Keep IDs stable to maintain `wevva.tcss` compatibility.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
CSS_PATH = "wevva.tcss" # single theme stylesheet
|
|
30
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
|
|
31
|
+
("q", "quit", "Quit"), # exit the app
|
|
32
|
+
("s", "search", "Search"), # open place search screen
|
|
33
|
+
("r", "refresh", "Refresh"), # fetch latest forecast
|
|
34
|
+
("h", "help", "Help"), # show quick help
|
|
35
|
+
("u", "settings", "Units"), # open settings
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
initial_location: LocationMetadata | None = None,
|
|
41
|
+
emoji_enabled: bool = True,
|
|
42
|
+
theme_name: str | None = None,
|
|
43
|
+
temperature_unit: str = "celsius",
|
|
44
|
+
wind_speed_unit: str = "kmh",
|
|
45
|
+
precipitation_unit: str = "mm",
|
|
46
|
+
**kwargs,
|
|
47
|
+
):
|
|
48
|
+
"""Initialize application (no postcode required; starts with place search).
|
|
49
|
+
|
|
50
|
+
Sets up controller, location state, and a refresh guard.
|
|
51
|
+
"""
|
|
52
|
+
super().__init__(**kwargs)
|
|
53
|
+
self.controller = WeatherController(
|
|
54
|
+
temperature_unit=temperature_unit,
|
|
55
|
+
wind_speed_unit=wind_speed_unit,
|
|
56
|
+
precipitation_unit=precipitation_unit,
|
|
57
|
+
)
|
|
58
|
+
self.sub_title = (
|
|
59
|
+
"Weather data from Open-Meteo" # static subtitle for all screens
|
|
60
|
+
)
|
|
61
|
+
self.forecast_metadata = None # LocationMetadata after first fetch
|
|
62
|
+
# unified location context (holds geocoded place + last forecast metadata)
|
|
63
|
+
self.location = initial_location or LocationMetadata() # set from CLI or search
|
|
64
|
+
# Track whether the app started with a CLI-provided location and any successful fetch yet
|
|
65
|
+
self.started_with_cli_location = initial_location is not None
|
|
66
|
+
self._has_successful_fetch = False
|
|
67
|
+
# guard to prevent overlapping refreshes
|
|
68
|
+
self._refresh_in_flight = False # debounce concurrent refreshes
|
|
69
|
+
# Emoji rendering toggle (widgets can read via self.app.emoji_enabled)
|
|
70
|
+
self.emoji_enabled = bool(emoji_enabled)
|
|
71
|
+
# Store unit preferences for widgets
|
|
72
|
+
self.temperature_unit = temperature_unit
|
|
73
|
+
self.wind_speed_unit = wind_speed_unit
|
|
74
|
+
self.precipitation_unit = precipitation_unit
|
|
75
|
+
# Initialize main weather screen once
|
|
76
|
+
self.weather_screen = WeatherScreen()
|
|
77
|
+
# Theme selection from CLI (validated by Textual during assignment)
|
|
78
|
+
if theme_name is not None:
|
|
79
|
+
self.theme = theme_name
|
|
80
|
+
|
|
81
|
+
async def on_mount(self):
|
|
82
|
+
"""Start with search screen, or if location provided via CLI, fetch weather directly."""
|
|
83
|
+
if self.location.latitude is not None and self.location.longitude is not None:
|
|
84
|
+
self.push_screen(self.weather_screen)
|
|
85
|
+
await self.action_refresh()
|
|
86
|
+
else:
|
|
87
|
+
self.push_screen(SearchScreen())
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------
|
|
90
|
+
# Actions / key bindings
|
|
91
|
+
# ------------------------------------------------------------
|
|
92
|
+
async def action_refresh(self) -> None:
|
|
93
|
+
"""Fetch via controller and broadcast `WeatherUpdated`.
|
|
94
|
+
|
|
95
|
+
Uses an in-flight guard to prevent overlapping requests.
|
|
96
|
+
"""
|
|
97
|
+
if self._refresh_in_flight:
|
|
98
|
+
return
|
|
99
|
+
self._refresh_in_flight = True
|
|
100
|
+
try:
|
|
101
|
+
event = await self.controller.fetch(
|
|
102
|
+
lat=self.location.latitude,
|
|
103
|
+
lon=self.location.longitude,
|
|
104
|
+
country_code=self.location.country_code,
|
|
105
|
+
)
|
|
106
|
+
# Forward fresh data to the weather screen
|
|
107
|
+
self.weather_screen.post_message(event)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
# Forward error to the weather screen to surface it
|
|
110
|
+
self.weather_screen.post_message(WeatherFetchFailed(e))
|
|
111
|
+
finally:
|
|
112
|
+
self._refresh_in_flight = False
|
|
113
|
+
|
|
114
|
+
def action_search(self): # textual binding: 's'
|
|
115
|
+
"""Open place search screen (fresh instance)."""
|
|
116
|
+
self.push_screen(SearchScreen())
|
|
117
|
+
|
|
118
|
+
def action_help(self): # textual binding: 'h'
|
|
119
|
+
"""Open help screen."""
|
|
120
|
+
self.push_screen(HelpScreen())
|
|
121
|
+
|
|
122
|
+
def action_settings(self) -> None:
|
|
123
|
+
"""Open settings screen and handle result via callback."""
|
|
124
|
+
self.push_screen(
|
|
125
|
+
SettingsScreen(
|
|
126
|
+
temperature_unit=self.temperature_unit,
|
|
127
|
+
wind_speed_unit=self.wind_speed_unit,
|
|
128
|
+
precipitation_unit=self.precipitation_unit,
|
|
129
|
+
),
|
|
130
|
+
callback=self._on_settings_result,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def _on_settings_result(self, result: dict | None) -> None:
|
|
134
|
+
"""Handle settings screen dismiss with optional result."""
|
|
135
|
+
# If user clicked Apply (result is dict), update units and refresh
|
|
136
|
+
if result:
|
|
137
|
+
self.temperature_unit = result["temperature_unit"]
|
|
138
|
+
self.wind_speed_unit = result["wind_speed_unit"]
|
|
139
|
+
self.precipitation_unit = result["precipitation_unit"]
|
|
140
|
+
|
|
141
|
+
# Update controller with new units
|
|
142
|
+
self.controller = WeatherController(
|
|
143
|
+
temperature_unit=self.temperature_unit,
|
|
144
|
+
wind_speed_unit=self.wind_speed_unit,
|
|
145
|
+
precipitation_unit=self.precipitation_unit,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Refresh weather with new units
|
|
149
|
+
if (
|
|
150
|
+
self.location.latitude is not None
|
|
151
|
+
and self.location.longitude is not None
|
|
152
|
+
):
|
|
153
|
+
await self.action_refresh()
|
|
154
|
+
|
|
155
|
+
# ---------------- Messages ----------------
|
|
156
|
+
async def on_place_selected(self, message: PlaceSelected) -> None:
|
|
157
|
+
"""Handle place selection → fetch → show main content.
|
|
158
|
+
|
|
159
|
+
Adopts selected location first so widgets see correct context on WeatherUpdated.
|
|
160
|
+
"""
|
|
161
|
+
self.location = message.location
|
|
162
|
+
self.push_screen(self.weather_screen)
|
|
163
|
+
await self.action_refresh()
|
|
164
|
+
|
|
165
|
+
async def on_weather_updated(self, event: WeatherUpdated) -> None:
|
|
166
|
+
"""Cache forecast metadata and merge API data into location."""
|
|
167
|
+
self.forecast_metadata = event.metadata
|
|
168
|
+
# Merge API-provided fields into app location
|
|
169
|
+
if event.metadata.elevation is not None:
|
|
170
|
+
self.location.elevation = event.metadata.elevation
|
|
171
|
+
if event.metadata.timezone_abbreviation:
|
|
172
|
+
self.location.timezone_abbreviation = event.metadata.timezone_abbreviation
|
|
173
|
+
self._has_successful_fetch = True
|
|
174
|
+
|
|
175
|
+
async def on_weather_fetch_failed(self, event: WeatherFetchFailed) -> None:
|
|
176
|
+
"""Show error notification; return to search if CLI location failed on first fetch."""
|
|
177
|
+
self.notify(
|
|
178
|
+
f"Refresh failed: {type(event.error).__name__}: {event.error}",
|
|
179
|
+
title="Weather Fetch Failed",
|
|
180
|
+
severity="error",
|
|
181
|
+
timeout=5.0,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# If CLI location failed on first fetch, return to search for recovery
|
|
185
|
+
if self.started_with_cli_location and not self._has_successful_fetch:
|
|
186
|
+
self.push_screen(SearchScreen())
|