├── .clippy.toml ├── demo ├── demo.gif ├── demo.mp4 └── demo.tape ├── screenshot.png ├── src ├── api │ ├── mod.rs │ ├── iplocation.rs │ ├── geolocation.rs │ ├── client.rs │ ├── location.rs │ └── weather.rs ├── tui │ ├── constants.rs │ ├── mod.rs │ ├── state_manager.rs │ ├── location_manager.rs │ ├── weather_display.rs │ ├── ui_components.rs │ ├── async_operations.rs │ └── keyboard_handlers.rs ├── utils │ ├── mod.rs │ ├── urls.rs │ ├── cache.rs │ ├── unitstrings.rs │ ├── conversions.rs │ ├── mappings.rs │ └── weather_classification.rs ├── output │ ├── json.rs │ ├── simple.rs │ ├── mod.rs │ ├── detailed.rs │ └── waybar.rs ├── settings.rs ├── main.rs └── context.rs ├── .gitignore ├── .rustfmt.toml ├── Cargo.toml ├── .goreleaser.yaml ├── CLAUDE.md ├── README.md └── LICENSE /.clippy.toml: -------------------------------------------------------------------------------- 1 | msrv = "1.80.0" 2 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BaconIsAVeg/outside/HEAD/demo/demo.gif -------------------------------------------------------------------------------- /demo/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BaconIsAVeg/outside/HEAD/demo/demo.mp4 -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BaconIsAVeg/outside/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | pub mod geolocation; 3 | pub mod iplocation; 4 | pub mod location; 5 | pub mod weather; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | mise.toml 2 | .justfile 3 | CLAUDE.md 4 | 5 | # Added by goreleaser init: 6 | dist/ 7 | .intentionally-empty-file.o 8 | target/ 9 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | hard_tabs = false 3 | max_width = 110 4 | tab_spaces = 4 5 | reorder_imports = true 6 | match_block_trailing_comma = true 7 | newline_style = "Unix" 8 | reorder_modules = true 9 | use_field_init_shorthand = true 10 | use_small_heuristics = "Max" 11 | -------------------------------------------------------------------------------- /src/tui/constants.rs: -------------------------------------------------------------------------------- 1 | pub const WEATHER_CACHE_DURATION: u64 = 600; // 10 minutes 2 | pub const LOCATION_LIST_WIDTH: usize = 24; 3 | pub const AUTO_REFRESH_INTERVAL: u64 = 6; // 1% of the 10 minutes until the API data is refreshed 4 | pub const LOCATION_LIST_NAME: &str = "location_list"; 5 | pub const WEATHER_HEADER_NAME: &str = "weather_header"; 6 | pub const WEATHER_CURRENT_NAME: &str = "weather_current"; 7 | pub const WEATHER_FORECAST_NAME: &str = "weather_forecast"; 8 | pub const DATA_AGE_PROGRESS_NAME: &str = "data_age_progress"; 9 | pub const NEW_LOCATION_NAME: &str = "new_location"; 10 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cache; 2 | pub mod conversions; 3 | pub mod mappings; 4 | pub mod unitstrings; 5 | pub mod urls; 6 | pub mod weather_classification; 7 | 8 | use std::time::{SystemTime, UNIX_EPOCH}; 9 | 10 | /// Returns the current Unix timestamp in seconds. 11 | /// 12 | /// This function provides a consistent way to get the current time for 13 | /// cache age calculations and timestamp comparisons throughout the application. 14 | /// 15 | /// # Returns 16 | /// 17 | /// Returns the number of seconds since the Unix epoch (1970-01-01 00:00:00 UTC). 18 | /// 19 | /// # Panics 20 | /// 21 | /// Panics if the system time is before the Unix epoch (extremely unlikely on modern systems). 22 | pub fn get_now() -> u64 { 23 | SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/urls.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | /// Builds a URL with query parameters from a base URL and parameter list. 4 | /// 5 | /// Takes a base URL and a vector of key-value pairs, then constructs a complete 6 | /// URL with properly encoded query parameters. This is used throughout the 7 | /// application for building API requests with multiple parameters. 8 | /// 9 | /// # Arguments 10 | /// 11 | /// * `base_url` - The base URL string to build upon 12 | /// * `params` - Vector of (key, value) tuples to add as query parameters 13 | /// 14 | /// # Returns 15 | /// 16 | /// Returns a complete URL string with query parameters appended. 17 | /// 18 | /// # Panics 19 | /// 20 | /// Panics if the base URL cannot be parsed as a valid URL. 21 | pub fn builder(base_url: &str, params: Vec<(&str, &str)>) -> String { 22 | let mut url = Url::parse(base_url).expect("Unable to parse base URL"); 23 | 24 | url.query_pairs_mut().clear(); 25 | for (key, value) in params { 26 | url.query_pairs_mut().append_pair(key, value); 27 | } 28 | 29 | url.to_string() 30 | } 31 | -------------------------------------------------------------------------------- /demo/demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | Output demo.mp4 3 | 4 | Set Shell "zsh" 5 | Set FontSize 16 6 | Set FontFamily "MonaspiceKr Nerd Font Mono" 7 | Set Width 1100 8 | Set Height 600 9 | Set Padding 10 10 | Set WindowBar Colorful 11 | Set TypingSpeed 65ms 12 | Set Framerate 30 13 | Set CursorBlink true 14 | Set Margin 20 15 | Set MarginFill "#406080" 16 | Set BorderRadius 10 17 | 18 | # Setup 19 | # Hide 20 | # Do stuff 21 | # Enter Wait 22 | # Show 23 | 24 | Sleep 500ms 25 | 26 | Type "outside -h" Sleep 500ms Enter Wait Sleep 2s Enter 27 | Type "outside -l 'Rome, IT' -o simple" Sleep 500ms Enter Wait Sleep 2s Enter 28 | Type "outside -l 'Berlin, DE' -o detailed" Sleep 500ms Enter Wait Sleep 2s Enter 29 | Type "outside -l 'Ottawa, CA' -o waybar -s -i 1 | jq" Sleep 500ms Enter Sleep 1500ms Enter 30 | Ctrl+C 31 | Sleep 500ms 32 | Type "outside" Sleep 500ms Enter Sleep 500ms 33 | Down 4 Sleep 500ms Enter Sleep 2s 34 | Type "afoo, bar" Sleep 500ms Enter Sleep 2s Enter Sleep 2s 35 | Type "alos angeles,us" Sleep 500ms Enter Sleep 2s Enter Sleep 1s 36 | 37 | Type "f" Sleep 2s 38 | Type "u" Sleep 2s 39 | Type "d" Sleep 2s Enter Sleep 2s 40 | Type "q" Sleep 1s 41 | 42 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "outside" 3 | version = "0.5.0" 4 | edition = "2021" 5 | authors = ["BaconIsAVeg "] 6 | license = "AGPL-3.0-or-later" 7 | readme = "README.md" 8 | description = "A multi-purpose weather client for your terminal" 9 | homepage = "https://github.com/BaconIsAVeg/outside" 10 | repository = "https://github.com/BaconIsAVeg/outside" 11 | keywords = ["cli", "weather"] 12 | categories = ["command-line-utilities"] 13 | 14 | [dependencies] 15 | isahc = { version = "1.7.2", features = ["json"] } 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1.0" 18 | serde_yaml = "0.9" 19 | serde_with = { version = "3.0", default-features = false, features = ["macros"] } 20 | 21 | clap = { version = "4.2", features = ["derive"] } 22 | cli-settings-derive = "0.3" 23 | anyhow = "1.0" 24 | 25 | url = "2.5.4" 26 | chrono = "0.4.41" 27 | 28 | dirs-next = "2.0.0" 29 | savefile = "0.19" 30 | savefile-derive = "0.19" 31 | tinytemplate = "1.2.1" 32 | openssl = { version = "0.10", features = ["vendored"] } 33 | tokio = { version = "1.46", features = ["rt-multi-thread", "time", "signal", "macros"] } 34 | cursive = "0.21" 35 | stringcase = "0.4" 36 | termsize = "0.1.9" 37 | 38 | [lints.rust] 39 | unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } 40 | -------------------------------------------------------------------------------- /src/output/json.rs: -------------------------------------------------------------------------------- 1 | use crate::context::Context; 2 | use crate::output::Output; 3 | use crate::Settings; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Serialize, Deserialize, Debug)] 7 | pub struct JsonOutput { 8 | pub template: String, 9 | } 10 | 11 | impl Output for JsonOutput { 12 | /// Creates a new JsonOutput instance with serialized context data. 13 | /// 14 | /// Converts the entire context structure to JSON format, providing 15 | /// access to all weather data fields for programmatic consumption. 16 | /// 17 | /// # Arguments 18 | /// 19 | /// * `context` - Weather and location data to be serialized 20 | /// * `_` - Settings parameter (unused for JSON output) 21 | /// 22 | /// # Returns 23 | /// 24 | /// Returns a JsonOutput instance with the serialized context data. 25 | fn new(context: Context, _: Settings) -> Self { 26 | let template = serde_json::to_string(&context) 27 | .unwrap_or_else(|_| "{{\"error\": \"Unable to serialize Context\"}}".to_string()); 28 | JsonOutput { template } 29 | } 30 | 31 | /// Returns the JSON-formatted weather output. 32 | /// 33 | /// # Returns 34 | /// 35 | /// Returns the complete context data as a JSON string. 36 | fn render(&self) -> String { 37 | self.template.clone() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/cache.rs: -------------------------------------------------------------------------------- 1 | use std::collections::hash_map::DefaultHasher; 2 | use std::hash::{Hash, Hasher}; 3 | 4 | /// Generates a cache file path for the given data type and content. 5 | /// 6 | /// Creates a hashed filename to ensure unique cache files for different 7 | /// locations. The cache directory is created if it doesn't exist, and the 8 | /// filename includes a hash of the content to prevent cache conflicts. 9 | /// 10 | /// # Arguments 11 | /// 12 | /// * `datatype` - The type of data being cached (e.g., "weather", "location") 13 | /// * `content` - The content identifier (e.g., location string) 14 | /// 15 | /// # Returns 16 | /// 17 | /// Returns the full path to the cache file as a string. 18 | /// 19 | /// # Panics 20 | /// 21 | /// Panics if the cache directory cannot be created. 22 | pub fn get_cached_file(datatype: &str, content: &str) -> String { 23 | let mut hasher = DefaultHasher::new(); 24 | content.hash(&mut hasher); 25 | 26 | let hash = format!("{:x}", hasher.finish()); 27 | 28 | std::fs::create_dir_all( 29 | dirs_next::cache_dir() 30 | .unwrap_or_else(|| dirs_next::home_dir().unwrap_or_default()) 31 | .join(env!("CARGO_PKG_NAME")), 32 | ) 33 | .unwrap_or_else(|_| panic!("Unable to create the cache directory for {}", env!("CARGO_PKG_NAME"))); 34 | 35 | dirs_next::cache_dir() 36 | .unwrap_or_else(|| dirs_next::home_dir().unwrap_or_default()) 37 | .join(env!("CARGO_PKG_NAME")) 38 | .join(format!("{datatype}-{hash}.cache")) 39 | .display() 40 | .to_string() 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/unitstrings.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 4 | pub struct UnitStrings { 5 | pub temperature: String, 6 | pub wind_speed: String, 7 | pub precipitation: String, 8 | } 9 | 10 | impl UnitStrings { 11 | /// Creates a UnitStrings instance with metric unit strings. 12 | /// 13 | /// Returns unit strings appropriate for metric measurements: 14 | /// - Temperature: "celsius" 15 | /// - Wind speed: "kmh" (kilometers per hour) 16 | /// - Precipitation: "mm" (millimeters) 17 | /// 18 | /// # Returns 19 | /// 20 | /// Returns a UnitStrings struct configured for metric units. 21 | pub fn metric() -> Self { 22 | UnitStrings { 23 | temperature: "celsius".to_string(), 24 | wind_speed: "kmh".to_string(), 25 | precipitation: "mm".to_string(), 26 | } 27 | } 28 | 29 | /// Creates a UnitStrings instance with imperial unit strings. 30 | /// 31 | /// Returns unit strings appropriate for imperial measurements: 32 | /// - Temperature: "fahrenheit" 33 | /// - Wind speed: "mph" (miles per hour) 34 | /// - Precipitation: "inch" (inches) 35 | /// 36 | /// # Returns 37 | /// 38 | /// Returns a UnitStrings struct configured for imperial units. 39 | pub fn imperial() -> Self { 40 | UnitStrings { 41 | temperature: "fahrenheit".to_string(), 42 | wind_speed: "mph".to_string(), 43 | precipitation: "inch".to_string(), 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 3 | 4 | version: 2 5 | 6 | before: 7 | hooks: 8 | - rustup default stable 9 | - cargo install --locked cargo-zigbuild 10 | - cargo fetch --locked 11 | 12 | builds: 13 | - builder: rust 14 | flags: 15 | - --release 16 | targets: 17 | - x86_64-unknown-linux-musl 18 | - aarch64-unknown-linux-musl 19 | 20 | nfpms: 21 | - maintainer: BaconIsAVeg 22 | homepage: https://github.com/BaconIsAVeg/outside 23 | description: A multi-purpose weather client for your terminal 24 | section: utils 25 | license: AGPL-3.0-or-later 26 | file_name_template: "{{ .ConventionalFileName }}" 27 | formats: 28 | - deb 29 | - apk 30 | - rpm 31 | contents: 32 | - src: LICENSE 33 | dst: /usr/share/doc/outside/LICENSE 34 | - src: README.md 35 | dst: /usr/share/doc/outside/README.md 36 | overrides: 37 | deb: 38 | dependencies: 39 | - ca-certificates (>= 20240203) 40 | - openssl (>= 3.0) 41 | 42 | archives: 43 | - formats: [tar.gz] 44 | name_template: >- 45 | {{ .ProjectName }}-{{ .Version }}_ 46 | {{- title .Os }}_ 47 | {{- if eq .Arch "amd64" }}x86_64 48 | {{- else if eq .Arch "386" }}i386 49 | {{- else }}{{ .Arch }}{{ end }} 50 | 51 | changelog: 52 | sort: asc 53 | filters: 54 | exclude: 55 | - "^docs:" 56 | - "^test:" 57 | - "^chore:" 58 | 59 | release: 60 | github: 61 | owner: BaconIsAVeg 62 | name: outside 63 | 64 | footer: >- 65 | 66 | -------------------------------------------------------------------------------- /src/output/simple.rs: -------------------------------------------------------------------------------- 1 | use crate::context::Context; 2 | use crate::output::Output; 3 | use crate::Settings; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | const DEFAULT_TEMPLATE: &str = 7 | "{weather_description} {temperature | round}{temperature_unit} | Wind {wind_speed | round}{wind_gusts | round}{{if precipitation_chance}} | Precipitation {precipitation_chance}%{{endif}}"; 8 | 9 | #[derive(Serialize, Deserialize, Debug)] 10 | pub struct SimpleOutput { 11 | pub template: String, 12 | } 13 | 14 | impl Output for SimpleOutput { 15 | /// Creates a new SimpleOutput instance with rendered template. 16 | /// 17 | /// Processes the context data through a customizable template to produce 18 | /// a concise, single-line weather summary suitable for status bars and 19 | /// simple displays. 20 | /// 21 | /// # Arguments 22 | /// 23 | /// * `context` - Weather and location data to be formatted 24 | /// * `settings` - Settings containing the optional custom template 25 | /// 26 | /// # Returns 27 | /// 28 | /// Returns a SimpleOutput instance with the rendered template. 29 | fn new(context: Context, settings: Settings) -> Self { 30 | let mut tt = Self::tt(); 31 | let text_template = settings.simple.template.unwrap_or(DEFAULT_TEMPLATE.to_string()); 32 | 33 | tt.add_template("text", text_template.as_str()).expect("Failed to add text template"); 34 | 35 | let template = 36 | tt.render("text", &context).unwrap_or_else(|_| "Error rendering text template".to_string()); 37 | 38 | SimpleOutput { template } 39 | } 40 | 41 | /// Returns the rendered simple weather output. 42 | /// 43 | /// # Returns 44 | /// 45 | /// Returns the simple weather output as a single-line string. 46 | fn render(&self) -> String { 47 | self.template.clone() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/api/iplocation.rs: -------------------------------------------------------------------------------- 1 | use crate::api::client; 2 | use crate::api::location::*; 3 | use crate::utils; 4 | 5 | use anyhow::{Context, Result}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Serialize, Deserialize, Debug)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct IPLocation { 11 | pub city: String, 12 | pub country_code: String, 13 | pub lat: f64, 14 | pub lon: f64, 15 | } 16 | 17 | impl Location for IPLocation { 18 | /// Fetches location data based on the client's IP address. 19 | /// 20 | /// Uses the ip-api.com service to determine the user's location based on their 21 | /// public IP address. This is used when no explicit location is provided. 22 | /// 23 | /// # Arguments 24 | /// 25 | /// * `_` - Unused parameter (name), kept for trait compatibility 26 | /// * `_` - Unused parameter (country_code), kept for trait compatibility 27 | /// 28 | /// # Returns 29 | /// 30 | /// Returns `LocationData` containing the detected location and coordinates. 31 | /// 32 | /// # Errors 33 | /// 34 | /// This function will return an error if: 35 | /// - The API request fails 36 | /// - The JSON response cannot be parsed 37 | /// - Network connectivity issues prevent IP detection 38 | fn fetch(_: &str, _: &str) -> Result { 39 | let base_url = "http://ip-api.com/json"; 40 | let api_url = utils::urls::builder(base_url, vec![("fields", "33603794")]); 41 | 42 | let body = 43 | client::get_with_retry(&api_url, 2).with_context(|| "Unable to fetch IP-based location data")?; 44 | 45 | let loc: IPLocation = 46 | serde_json::from_str(&body).with_context(|| "Unable to parse IP location response JSON")?; 47 | 48 | let mut location_data = LocationData { 49 | city: loc.city.to_owned(), 50 | country_code: loc.country_code.to_owned(), 51 | latitude: loc.lat, 52 | longitude: loc.lon, 53 | location: "".to_string(), 54 | created_at: utils::get_now(), 55 | }; 56 | 57 | // Normalize the location data for consistent formatting 58 | location_data.normalize(); 59 | 60 | Ok(location_data) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/output/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod detailed; 2 | pub mod json; 3 | pub mod simple; 4 | pub mod waybar; 5 | 6 | use crate::context::Context; 7 | use crate::Settings; 8 | 9 | use std::fmt::Write; 10 | use tinytemplate::TinyTemplate; 11 | 12 | /// Trait for different weather output formats. 13 | /// 14 | /// This trait defines the interface for converting weather context data into 15 | /// formatted output strings. Each output format implements this trait to provide 16 | /// its own rendering logic and template system. 17 | pub trait Output { 18 | /// Creates a new output instance with the given context and settings. 19 | /// 20 | /// # Arguments 21 | /// 22 | /// * `context` - Weather and location data to be formatted 23 | /// * `settings` - User configuration including templates and preferences 24 | /// 25 | /// # Returns 26 | /// 27 | /// Returns a new instance of the output formatter configured with the provided data. 28 | fn new(context: Context, settings: Settings) -> Self; 29 | 30 | /// Renders the output as a formatted string. 31 | /// 32 | /// # Returns 33 | /// 34 | /// Returns the formatted weather information as a string ready for display. 35 | fn render(&self) -> String; 36 | 37 | /// Returns a configured TinyTemplate instance with custom formatters. 38 | /// 39 | /// Sets up the template engine with custom formatters like the `round` filter 40 | /// for formatting numeric values in templates. 41 | /// 42 | /// # Returns 43 | /// 44 | /// Returns a TinyTemplate instance ready for use with weather data. 45 | fn tt() -> TinyTemplate<'static> { 46 | let mut tt = TinyTemplate::new(); 47 | tt.add_formatter("round", |value, output| { 48 | write!(output, "{:.0}", value.as_f64().unwrap_or(0.0).round())?; 49 | Ok(()) 50 | }); 51 | tt 52 | } 53 | } 54 | 55 | /// Generic function to render weather data using any output format. 56 | /// 57 | /// This function provides a polymorphic way to render weather data by accepting 58 | /// any type that implements the `Output` trait. It creates an instance of the 59 | /// specified output format and renders it to a string. 60 | /// 61 | /// # Type Parameters 62 | /// 63 | /// * `O` - The output format type that implements the `Output` trait 64 | /// 65 | /// # Arguments 66 | /// 67 | /// * `context` - Weather and location data to be formatted 68 | /// * `settings` - User configuration including templates and preferences 69 | /// 70 | /// # Returns 71 | /// 72 | /// Returns the formatted weather information as a string. 73 | pub fn render_output(context: Context, settings: Settings) -> String { 74 | let output = O::new(context, settings); 75 | output.render() 76 | } 77 | -------------------------------------------------------------------------------- /src/api/geolocation.rs: -------------------------------------------------------------------------------- 1 | use crate::api::client; 2 | use crate::api::location::*; 3 | use crate::utils; 4 | 5 | use anyhow::{Context, Result}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Serialize, Deserialize, Debug)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct GeoLocation { 11 | pub results: Vec, 12 | } 13 | 14 | #[derive(serde::Deserialize, serde::Serialize, Debug)] 15 | pub struct Results { 16 | pub name: String, 17 | pub country_code: String, 18 | pub latitude: f64, 19 | pub longitude: f64, 20 | } 21 | 22 | impl Location for GeoLocation { 23 | /// Fetches location data using the Open-Meteo geocoding API. 24 | /// 25 | /// Searches for a location by name and country code, returning the first result. 26 | /// Uses the Open-Meteo geocoding API to find coordinates for the specified location. 27 | /// 28 | /// # Arguments 29 | /// 30 | /// * `n` - The city or location name to search for 31 | /// * `c` - The country code to narrow down the search 32 | /// 33 | /// # Returns 34 | /// 35 | /// Returns `LocationData` containing the location details and coordinates. 36 | /// 37 | /// # Errors 38 | /// 39 | /// This function will return an error if: 40 | /// - The API request fails 41 | /// - The JSON response cannot be parsed 42 | /// - No results are found for the specified location 43 | fn fetch(n: &str, c: &str) -> Result { 44 | let base_url = "https://geocoding-api.open-meteo.com/v1/search"; 45 | let params = 46 | vec![("name", n), ("countryCode", c), ("count", "10"), ("language", "en"), ("format", "json")]; 47 | let api_url = utils::urls::builder(base_url, params); 48 | 49 | let body = client::get_with_retry(&api_url, 2) 50 | .with_context(|| format!("Unable to fetch location data for {n}, {c}"))?; 51 | 52 | let loc: GeoLocation = 53 | serde_json::from_str(&body).with_context(|| "Failed to parse location response JSON")?; 54 | 55 | if loc.results.is_empty() { 56 | return Err(anyhow::anyhow!("No location results found for {}, {}", n, c)); 57 | } 58 | 59 | let result = &loc.results[0]; 60 | 61 | let mut location_data = LocationData { 62 | city: result.name.to_owned(), 63 | country_code: result.country_code.to_owned(), 64 | latitude: result.latitude, 65 | longitude: result.longitude, 66 | location: format!("{}, {}", result.name, result.country_code), 67 | created_at: utils::get_now(), 68 | }; 69 | 70 | // Normalize the location data for consistent formatting 71 | location_data.normalize(); 72 | 73 | Ok(location_data) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/conversions.rs: -------------------------------------------------------------------------------- 1 | use chrono::{NaiveDate, NaiveDateTime}; 2 | 3 | /// Converts an ISO8601 datetime string to a human-readable time format. 4 | /// 5 | /// Takes a datetime string in the format "YYYY-MM-DDTHH:MM" and converts it 6 | /// to either 12-hour format with AM/PM indicator (e.g., "08:30am") or 24-hour 7 | /// format (e.g., "20:30") based on the hour24 parameter. 8 | /// 9 | /// # Arguments 10 | /// 11 | /// * `iso8601` - A datetime string in ISO8601 format 12 | /// * `hour24` - If true, returns 24-hour format; if false, returns 12-hour format 13 | /// 14 | /// # Returns 15 | /// 16 | /// Returns a formatted time string in the requested format. 17 | /// 18 | /// # Panics 19 | /// 20 | /// Panics if the input string cannot be parsed as a valid ISO8601 datetime. 21 | pub fn iso8601_to_time(iso8601: String, hour24: bool) -> String { 22 | let dt = NaiveDateTime::parse_from_str(&iso8601, "%Y-%m-%dT%H:%M").unwrap(); 23 | if hour24 { 24 | dt.format("%H:%M").to_string() 25 | } else { 26 | dt.format("%I:%M%P").to_string() 27 | } 28 | } 29 | 30 | /// Converts an ISO8601 date string to a human-readable date format. 31 | /// 32 | /// Takes a date string in the format "YYYY-MM-DD" and converts it to 33 | /// a readable format showing the day of week and month/day (e.g., "Mon 03/15"). 34 | /// 35 | /// # Arguments 36 | /// 37 | /// * `iso8601` - A date string in ISO8601 format 38 | /// 39 | /// # Returns 40 | /// 41 | /// Returns a formatted date string with abbreviated day name and MM/DD format. 42 | /// 43 | /// # Panics 44 | /// 45 | /// Panics if the input string cannot be parsed as a valid ISO8601 date. 46 | pub fn iso8601_to_date(iso8601: String) -> String { 47 | let dt = NaiveDate::parse_from_str(&iso8601, "%Y-%m-%d").unwrap(); 48 | dt.format("%a %m/%d").to_string() 49 | } 50 | 51 | /// Converts temperature from Celsius to Fahrenheit. 52 | /// 53 | /// # Arguments 54 | /// 55 | /// * `celsius` - Temperature in Celsius 56 | /// 57 | /// # Returns 58 | /// 59 | /// Returns temperature in Fahrenheit, rounded to 1 decimal place. 60 | pub fn celsius_to_fahrenheit(celsius: f64) -> f64 { 61 | (((celsius * 9.0 / 5.0) + 32.0) * 10.0).round() / 10.0 62 | } 63 | 64 | /// Converts wind speed from km/h to mph. 65 | /// 66 | /// # Arguments 67 | /// 68 | /// * `kmh` - Wind speed in kilometers per hour 69 | /// 70 | /// # Returns 71 | /// 72 | /// Returns wind speed in miles per hour, rounded to 1 decimal place. 73 | pub fn kmh_to_mph(kmh: f64) -> f64 { 74 | (kmh * 0.621371 * 10.0).round() / 10.0 75 | } 76 | 77 | /// Converts precipitation from millimeters to inches. 78 | /// 79 | /// # Arguments 80 | /// 81 | /// * `mm` - Precipitation in millimeters 82 | /// 83 | /// # Returns 84 | /// 85 | /// Returns precipitation in inches, rounded to 1 decimal place. 86 | pub fn mm_to_inches(mm: f64) -> f64 { 87 | (mm * 0.0393701 * 10.0).round() / 10.0 88 | } 89 | -------------------------------------------------------------------------------- /src/output/detailed.rs: -------------------------------------------------------------------------------- 1 | use crate::context::Context; 2 | use crate::output::Output; 3 | use crate::Settings; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Serialize, Deserialize, Debug)] 7 | pub struct DetailedOutput { 8 | pub template: String, 9 | } 10 | 11 | impl Output for DetailedOutput { 12 | /// Creates a new DetailedOutput instance with rendered template. 13 | /// 14 | /// Processes the context data through a comprehensive template that displays 15 | /// current weather conditions, atmospheric data, and a 7-day forecast. 16 | /// Uses a fixed template for consistent detailed output format. 17 | /// 18 | /// # Arguments 19 | /// 20 | /// * `context` - Weather and location data to be formatted 21 | /// * `_` - Settings parameter (unused for detailed output) 22 | /// 23 | /// # Returns 24 | /// 25 | /// Returns a DetailedOutput instance with the rendered template. 26 | fn new(context: Context, _: Settings) -> Self { 27 | let mut tt = Self::tt(); 28 | 29 | // Build dynamic template with precipitation timing 30 | let mut template_parts = vec![ 31 | "{city}, {country}".to_string(), 32 | " Current: {temperature}{temperature_unit} {weather_description}".to_string(), 33 | " Feels Like: {feels_like}{temperature_unit}".to_string(), 34 | " Humidity: {humidity}{humidity_unit}".to_string(), 35 | " Pressure: {pressure}{pressure_unit}".to_string(), 36 | " Wind: {wind_speed}{wind_speed_unit} with gusts up to {wind_gusts}{wind_speed_unit} ({wind_compass})".to_string(), 37 | " UV Index: {uv_index}".to_string(), 38 | ]; 39 | // Add precipitation with optional timing description 40 | let precip_line = if let Some(description) = &context.precipitation_description { 41 | format!(" Precip: {{precipitation_sum}} {{precipitation_unit}} ({{precipitation_chance}}% chance, {description})") 42 | } else { 43 | " Precip: {precipitation_sum} {precipitation_unit} ({precipitation_chance}% chance)" 44 | .to_string() 45 | }; 46 | template_parts.push(precip_line); 47 | 48 | template_parts.push(" Sunrise: {sunrise}".to_string()); 49 | template_parts.push(" Sunset: {sunset}".to_string()); 50 | template_parts.push("".to_string()); 51 | template_parts.push(" {{ for day in forecast -}}".to_string()); 52 | template_parts.push(" {day.date} {day.temperature_low | round}-{day.temperature_high | round}{temperature_unit} - {day.weather_description}".to_string()); 53 | template_parts.push(" {{ endfor }}".to_string()); 54 | 55 | let text_template = template_parts.join("\n"); 56 | tt.add_template("text", &text_template).expect("Failed to add text template"); 57 | 58 | let template = 59 | tt.render("text", &context).unwrap_or_else(|_| "Error rendering text template".to_string()); 60 | 61 | DetailedOutput { template } 62 | } 63 | 64 | /// Returns the rendered detailed weather output. 65 | /// 66 | /// # Returns 67 | /// 68 | /// Returns the detailed weather output as a multi-line string with 69 | /// current conditions and forecast information. 70 | fn render(&self) -> String { 71 | self.template.clone() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/output/waybar.rs: -------------------------------------------------------------------------------- 1 | use crate::context::Context; 2 | use crate::output::Output; 3 | use crate::utils::weather_classification; 4 | use crate::Settings; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | const DEFAULT_TEXT_TEMPLATE: &str = 8 | "{weather_icon} {temperature | round}{temperature_unit}{{if precipitation_sum}} 󰖗 {precipitation_chance}%{{endif}}"; 9 | const DEFAULT_TOOLTIP_TEMPLATE: &str = "{city}, {country}\n{weather_description}\nFeels Like {feels_like} {temperature_unit}\nForecast {temperature_low | round}-{temperature_high| round} {temperature_unit}\nHumidity {humidity}{humidity_unit}\nPressure {pressure} {pressure_unit}\nWind {wind_speed}{wind_gusts} {wind_speed_unit} ({wind_compass})\nPrecip {precipitation_sum} {precipitation_unit} ({precipitation_chance}% chance)\n{{if precipitation_description}} {precipitation_description}{{endif}}\n {sunrise}  {sunset}"; 10 | 11 | #[derive(Serialize, Deserialize, Debug)] 12 | pub struct WaybarOutput { 13 | pub text: String, 14 | pub tooltip: String, 15 | pub class: Vec, 16 | pub percentage: i8, 17 | } 18 | 19 | impl Output for WaybarOutput { 20 | /// Creates a new WaybarOutput instance with text, tooltip, and CSS classes. 21 | /// 22 | /// Generates Waybar-compatible JSON output with customizable text and tooltip 23 | /// templates, plus dynamic CSS classes based on weather conditions and 24 | /// temperature thresholds. 25 | /// 26 | /// CSS classes generated: 27 | /// - "hot" - when temperature exceeds configured hot threshold 28 | /// - "cold" - when temperature is below configured cold threshold 29 | /// - Weather condition classes ("fog", "snow", "rain") based on weather codes 30 | /// (see utils::weather_classification for specific ranges) 31 | /// 32 | /// # Arguments 33 | /// 34 | /// * `context` - Weather and location data to be formatted 35 | /// * `settings` - Settings containing templates and temperature thresholds 36 | /// 37 | /// # Returns 38 | /// 39 | /// Returns a WaybarOutput instance with formatted text, tooltip, and classes. 40 | fn new(context: Context, settings: Settings) -> Self { 41 | let mut tt = Self::tt(); 42 | let text_template = settings.waybar.text.unwrap_or(DEFAULT_TEXT_TEMPLATE.to_string()); 43 | let tooltip_template = settings.waybar.tooltip.unwrap_or(DEFAULT_TOOLTIP_TEMPLATE.to_string()); 44 | 45 | tt.add_template("text", text_template.as_str()).expect("Unable to add text template"); 46 | tt.add_template("tooltip", tooltip_template.as_str()).expect("Unable to add tooltip template"); 47 | 48 | let text = 49 | tt.render("text", &context).unwrap_or_else(|_| "Error rendering text template".to_string()); 50 | let tooltip = 51 | tt.render("tooltip", &context).unwrap_or_else(|_| "Error rendering tooltip template".to_string()); 52 | 53 | // Generate all CSS classes using the centralized utility 54 | let classes = weather_classification::get_all_weather_css_classes( 55 | context.weather_code, 56 | context.temperature, 57 | settings.waybar.hot_temperature, 58 | settings.waybar.cold_temperature, 59 | ); 60 | 61 | WaybarOutput { text, tooltip, class: classes, percentage: 100 } 62 | } 63 | 64 | /// Returns the Waybar-compatible JSON output. 65 | /// 66 | /// # Returns 67 | /// 68 | /// Returns the weather data formatted as JSON for Waybar consumption, 69 | /// including text, tooltip, CSS classes, and percentage fields. 70 | fn render(&self) -> String { 71 | serde_json::to_string(self).unwrap() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/api/client.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use isahc::config::Configurable; 3 | use isahc::{HttpClient, HttpClientBuilder, ReadResponseExt}; 4 | use std::sync::OnceLock; 5 | use std::time::Duration; 6 | 7 | static HTTP_CLIENT: OnceLock = OnceLock::new(); 8 | 9 | /// Returns a shared HTTP client instance configured with appropriate timeouts and connection pooling. 10 | /// 11 | /// The client is created once and reused for all HTTP requests to improve performance. 12 | /// Configuration includes: 13 | /// - 2 second connection timeout 14 | /// - 5 second request timeout 15 | /// - 15 second TCP keepalive 16 | /// - Maximum 4 connections per host 17 | /// 18 | /// # Returns 19 | /// 20 | /// Returns a reference to the global HTTP client instance. 21 | pub fn get_client() -> &'static HttpClient { 22 | HTTP_CLIENT.get_or_init(|| { 23 | HttpClientBuilder::new() 24 | .connect_timeout(Duration::from_secs(2)) 25 | .timeout(Duration::from_secs(5)) 26 | .tcp_keepalive(Duration::from_secs(15)) 27 | .max_connections_per_host(4) 28 | .build() 29 | .expect("Unable to create HTTP client") 30 | }) 31 | } 32 | 33 | /// Performs a GET request to the specified URL and returns the response body as a string. 34 | /// 35 | /// # Arguments 36 | /// 37 | /// * `url` - The URL to send the GET request to 38 | /// 39 | /// # Returns 40 | /// 41 | /// Returns the response body as a string on success, or an error if the request fails 42 | /// or the response status is not successful. 43 | /// 44 | /// # Errors 45 | /// 46 | /// This function will return an error if: 47 | /// - The HTTP request fails to send 48 | /// - The response status indicates failure 49 | /// - The response body cannot be read as text 50 | pub fn get(url: &str) -> Result { 51 | let client = get_client(); 52 | 53 | let mut response = client.get(url).with_context(|| format!("Unable to send request to {url}"))?; 54 | 55 | if !response.status().is_success() { 56 | return Err(anyhow::anyhow!( 57 | "HTTP request failed with status: {} for URL: {}", 58 | response.status(), 59 | url 60 | )); 61 | } 62 | 63 | response.text().with_context(|| format!("Unable to read response body from {url}")) 64 | } 65 | 66 | /// Performs a GET request with exponential backoff retry logic. 67 | /// 68 | /// Attempts the request up to `max_retries + 1` times (initial attempt plus retries). 69 | /// Uses exponential backoff with a base delay of 100ms, doubling on each retry. 70 | /// 71 | /// # Arguments 72 | /// 73 | /// * `url` - The URL to send the GET request to 74 | /// * `max_retries` - Maximum number of retry attempts after the initial request 75 | /// 76 | /// # Returns 77 | /// 78 | /// Returns the response body as a string on success, or the last error encountered 79 | /// if all attempts fail. 80 | /// 81 | /// # Errors 82 | /// 83 | /// This function will return an error if all retry attempts fail. The error 84 | /// returned will be from the final attempt. 85 | pub fn get_with_retry(url: &str, max_retries: usize) -> Result { 86 | let mut last_error = None; 87 | 88 | for attempt in 0..=max_retries { 89 | match get(url) { 90 | Ok(response) => return Ok(response), 91 | Err(e) => { 92 | last_error = Some(e); 93 | if attempt < max_retries { 94 | // Simple backoff strategy: wait 100ms * 2^attempt 95 | let delay = Duration::from_millis(100 * (2_u64.pow(attempt as u32))); 96 | std::thread::sleep(delay); 97 | } 98 | }, 99 | } 100 | } 101 | 102 | Err(last_error.unwrap()) 103 | } 104 | -------------------------------------------------------------------------------- /src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod async_operations; 2 | pub mod constants; 3 | pub mod keyboard_handlers; 4 | pub mod location_manager; 5 | pub mod state_manager; 6 | pub mod ui_components; 7 | pub mod weather_display; 8 | 9 | use crate::context::Context; 10 | use crate::output::Output; 11 | use crate::Settings; 12 | use async_operations::WeatherFetcher; 13 | use cursive::views::ResizedView; 14 | use cursive::{Cursive, CursiveExt}; 15 | use keyboard_handlers::KeyboardHandlers; 16 | use location_manager::LocationManager; 17 | use state_manager::TuiStateManager; 18 | use ui_components::UiComponents; 19 | 20 | #[derive(Debug)] 21 | pub struct TuiOutput { 22 | context: Context, 23 | settings: Settings, 24 | } 25 | 26 | impl Output for TuiOutput { 27 | /// Creates a new TuiOutput instance. 28 | /// 29 | /// The TUI output mode creates an interactive terminal interface 30 | /// displaying weather information in a structured layout. 31 | /// 32 | /// # Arguments 33 | /// 34 | /// * `context` - Weather and location data to be displayed 35 | /// * `settings` - Settings parameter for location management 36 | /// 37 | /// # Returns 38 | /// 39 | /// Returns a TuiOutput instance with the provided context. 40 | fn new(context: Context, settings: Settings) -> Self { 41 | TuiOutput { context, settings } 42 | } 43 | 44 | /// Renders the TUI interface and returns empty string. 45 | /// 46 | /// The TUI mode doesn't return text output but instead displays 47 | /// an interactive terminal interface. This method launches the 48 | /// cursive application and blocks until the user exits. 49 | /// 50 | /// # Returns 51 | /// 52 | /// Returns an empty string since output is handled by the TUI. 53 | fn render(&self) -> String { 54 | self.run_tui(); 55 | String::new() 56 | } 57 | } 58 | 59 | impl TuiOutput { 60 | /// Runs the interactive TUI interface. 61 | /// 62 | /// Creates a full-screen cursive application with weather information 63 | /// displayed across the entire terminal with location management on the right. 64 | /// Users can navigate using keyboard controls and exit by pressing 'q' or Escape. 65 | fn run_tui(&self) { 66 | let mut siv = Cursive::default(); 67 | 68 | // Set up theme with terminal default background and no shadows 69 | UiComponents::setup_theme(&mut siv); 70 | 71 | // Initialize managers 72 | let state_manager = TuiStateManager::new(self.context.clone(), self.settings.clone()); 73 | let location_manager = LocationManager::new(); 74 | let weather_fetcher = WeatherFetcher::new(state_manager.clone()); 75 | 76 | // Add current location to list if not present 77 | let current_location = location_manager.get_current_location_string(&self.settings.location); 78 | location_manager.ensure_location_in_list(current_location); 79 | 80 | // Create main layout 81 | let main_layout = UiComponents::create_main_layout(&state_manager, &location_manager, &self.settings); 82 | siv.add_fullscreen_layer(ResizedView::with_full_screen(main_layout)); 83 | 84 | // Set initial progress bar value based on current cache age 85 | let context = state_manager.get_context(); 86 | let initial_progress = ((context.cache_age as f64 / constants::WEATHER_CACHE_DURATION as f64) * 100.0) 87 | .min(100.0) as usize; 88 | siv.call_on_name(constants::DATA_AGE_PROGRESS_NAME, |view: &mut cursive::views::ProgressBar| { 89 | view.set_value(initial_progress); 90 | }); 91 | 92 | // Set up keyboard handlers 93 | KeyboardHandlers::setup_all_handlers( 94 | &mut siv, 95 | state_manager.clone(), 96 | location_manager, 97 | weather_fetcher.clone(), 98 | ); 99 | 100 | // Set up automatic refresh when cache expires 101 | weather_fetcher.setup_auto_refresh(&mut siv); 102 | 103 | // Run the TUI 104 | siv.run(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use crate::context::Context; 2 | use crate::output::*; 3 | use crate::utils::unitstrings::UnitStrings; 4 | use crate::Settings as OutsideSettings; 5 | 6 | use clap::ValueEnum; 7 | use cli_settings_derive::cli_settings; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | #[derive(ValueEnum, Clone, Debug, Serialize, Deserialize, Default, PartialEq)] 11 | pub enum Units { 12 | #[default] 13 | Metric, 14 | Imperial, 15 | } 16 | 17 | impl Units { 18 | /// Returns the string representation of the units for use in API calls. 19 | /// 20 | /// # Returns 21 | /// 22 | /// Returns "metric" for metric units or "imperial" for imperial units. 23 | pub fn as_str(&self) -> &'static str { 24 | match self { 25 | Units::Metric => "metric", 26 | Units::Imperial => "imperial", 27 | } 28 | } 29 | 30 | /// Converts the units enum to a `UnitStrings` struct for template rendering. 31 | /// 32 | /// # Returns 33 | /// 34 | /// Returns a `UnitStrings` struct containing the appropriate unit suffixes 35 | /// for display in output templates. 36 | pub fn to_unit_strings(&self) -> UnitStrings { 37 | match self { 38 | Units::Metric => UnitStrings::metric(), 39 | Units::Imperial => UnitStrings::imperial(), 40 | } 41 | } 42 | } 43 | 44 | #[derive(ValueEnum, Clone, Debug, Serialize, Deserialize, Default)] 45 | pub enum OutputFormat { 46 | #[default] 47 | Tui, 48 | Simple, 49 | Detailed, 50 | Json, 51 | Waybar, 52 | } 53 | 54 | impl OutputFormat { 55 | /// Returns the appropriate rendering function for the selected output format. 56 | /// 57 | /// Each output format has its own implementation of the `Output` trait, 58 | /// and this method returns the correct rendering function to use. 59 | /// 60 | /// # Returns 61 | /// 62 | /// Returns a function pointer that takes a `Context` and `OutsideSettings` 63 | /// and returns a formatted string for the selected output format. 64 | pub fn render_fn(&self) -> fn(Context, OutsideSettings) -> String { 65 | match self { 66 | OutputFormat::Simple => render_output::, 67 | OutputFormat::Detailed => render_output::, 68 | OutputFormat::Json => render_output::, 69 | OutputFormat::Waybar => render_output::, 70 | OutputFormat::Tui => render_output::, 71 | } 72 | } 73 | } 74 | 75 | #[serde_with::skip_serializing_none] 76 | #[derive(Clone, Debug, Deserialize, Default)] 77 | pub struct WaybarConfig { 78 | pub text: Option, 79 | pub tooltip: Option, 80 | pub hot_temperature: Option, 81 | pub cold_temperature: Option, 82 | } 83 | 84 | #[serde_with::skip_serializing_none] 85 | #[derive(Clone, Debug, Deserialize, Default)] 86 | pub struct SimpleConfig { 87 | pub template: Option, 88 | } 89 | 90 | #[derive(Debug, Clone)] 91 | #[cli_settings] 92 | #[cli_settings_file = "#[serde_with::serde_as]#[derive(serde::Deserialize)]"] 93 | /// A multi-purpose weather client for your terminal 94 | #[cli_settings_clap = "#[derive(clap::Parser)]#[command(name = \"outside\", version, verbatim_doc_comment)]"] 95 | pub struct Settings { 96 | /// Location to fetch weather data for, 97 | /// leave blank to auto-detect using your IP address 98 | #[cli_settings_file] 99 | #[cli_settings_clap = "#[arg(short, long, verbatim_doc_comment)]"] 100 | pub location: String, 101 | 102 | /// Units of measurement 103 | #[cli_settings_file] 104 | #[cli_settings_clap = "#[arg(short, long, verbatim_doc_comment)]"] 105 | pub units: Units, 106 | 107 | /// Display format 108 | #[cli_settings_clap = "#[arg(short, long, verbatim_doc_comment)]"] 109 | pub output: OutputFormat, 110 | 111 | /// Enable streaming mode for continuous output 112 | #[cli_settings_clap = "#[arg(short, long, action = clap::ArgAction::SetTrue, verbatim_doc_comment)]"] 113 | pub stream: bool, 114 | 115 | /// Interval in seconds between streaming updates 116 | #[cli_settings_file] 117 | #[cli_settings_clap = "#[arg(short, long, default_value = \"30\", verbatim_doc_comment)]"] 118 | pub interval: u64, 119 | 120 | /// Use a 24-hour time format 121 | #[cli_settings_clap = "#[arg(long = \"24\", action = clap::ArgAction::SetTrue, verbatim_doc_comment)]"] 122 | pub hour24: bool, 123 | 124 | #[cli_settings_file] 125 | pub simple: SimpleConfig, 126 | 127 | #[cli_settings_file] 128 | pub waybar: WaybarConfig, 129 | } 130 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod context; 3 | pub mod output; 4 | pub mod settings; 5 | pub mod tui; 6 | pub mod utils; 7 | 8 | use crate::api::location::LocationData; 9 | use crate::api::weather; 10 | use crate::settings::{OutputFormat, Settings, Units}; 11 | use anyhow::Result; 12 | use std::time::Duration; 13 | use tokio::signal; 14 | use tokio::time::interval; 15 | 16 | /// Main entry point for the outside weather CLI application. 17 | /// 18 | /// This function orchestrates the complete weather data pipeline: 19 | /// 1. Builds configuration from config file and CLI arguments 20 | /// 2. Resolves location data (with caching) 21 | /// 3. Fetches weather data from Open-Meteo API (with caching) 22 | /// 4. Builds context for template rendering 23 | /// 5. Renders and outputs the weather information in the specified format 24 | /// 25 | /// Supports both single-run mode and streaming mode for continuous output. 26 | /// In streaming mode, weather data is fetched and output at regular intervals 27 | /// until the program receives a termination signal. 28 | /// 29 | /// # Returns 30 | /// 31 | /// Returns `Ok(())` on success, or an error if any step fails. 32 | #[tokio::main] 33 | async fn main() -> Result<()> { 34 | let config_file = dirs_next::config_dir() 35 | .unwrap_or_else(|| dirs_next::home_dir().unwrap_or_default()) 36 | .join(env!("CARGO_PKG_NAME")) 37 | .join("config.yaml"); 38 | 39 | let s = Settings::build(vec![config_file], std::env::args_os())?; 40 | 41 | // TUI mode is incompatible with streaming mode 42 | if s.stream && matches!(s.output, OutputFormat::Tui) { 43 | eprintln!("Error: TUI mode cannot be used with streaming mode."); 44 | std::process::exit(1); 45 | } 46 | 47 | if s.stream { 48 | run_streaming_mode(s).await 49 | } else { 50 | run_single_mode(s).await 51 | } 52 | } 53 | 54 | /// Runs the application in streaming mode for continuous output. 55 | /// 56 | /// Outputs weather data at regular intervals until interrupted by a signal. 57 | /// This mode is particularly useful for status bars like Waybar that expect 58 | /// continuous JSON output from external commands. 59 | /// 60 | /// # Arguments 61 | /// 62 | /// * `settings` - Application configuration including interval and output format 63 | /// 64 | /// # Returns 65 | /// 66 | /// Returns `Ok(())` when gracefully shutdown, or an error if critical failure occurs. 67 | async fn run_streaming_mode(settings: Settings) -> Result<()> { 68 | let mut timer = interval(Duration::from_secs(settings.interval)); 69 | 70 | // Output immediately on startup 71 | if let Err(e) = output_weather_data(&settings).await { 72 | eprintln!("Error fetching initial weather data: {e}"); 73 | } 74 | 75 | // Skip the first tick since interval.tick() fires immediately 76 | timer.tick().await; 77 | 78 | loop { 79 | tokio::select! { 80 | _ = timer.tick() => { 81 | if let Err(e) = output_weather_data(&settings).await { 82 | eprintln!("Error fetching weather data: {e}"); 83 | // Continue running even if one fetch fails 84 | continue; 85 | } 86 | } 87 | _ = signal::ctrl_c() => { 88 | if cfg!(debug_assertions) { 89 | eprintln!("Received interrupt signal, shutting down gracefully"); 90 | } 91 | break; 92 | } 93 | } 94 | } 95 | 96 | Ok(()) 97 | } 98 | 99 | /// Runs the application in single-run mode. 100 | /// 101 | /// Fetches weather data once, outputs it, and exits. This is the traditional 102 | /// behavior of the application. 103 | /// 104 | /// # Arguments 105 | /// 106 | /// * `settings` - Application configuration 107 | /// 108 | /// # Returns 109 | /// 110 | /// Returns `Ok(())` on success, or an error if any step fails. 111 | async fn run_single_mode(settings: Settings) -> Result<()> { 112 | output_weather_data(&settings).await 113 | } 114 | 115 | /// Fetches weather data and outputs it according to the configured format. 116 | /// 117 | /// This function encapsulates the core weather data pipeline that can be used 118 | /// in both single-run and streaming modes. 119 | /// 120 | /// # Arguments 121 | /// 122 | /// * `settings` - Application configuration 123 | /// 124 | /// # Returns 125 | /// 126 | /// Returns `Ok(())` on success, or an error if fetching or output fails. 127 | async fn output_weather_data(settings: &Settings) -> Result<()> { 128 | let loc = LocationData::get_cached(settings.clone())?; 129 | let weather = weather::Weather::get_cached(loc.latitude, loc.longitude, settings.clone())?; 130 | 131 | let context = context::Context::build(weather, loc, settings.clone()); 132 | let output = settings.output.render_fn()(context, settings.clone()); 133 | 134 | println!("{output}"); 135 | Ok(()) 136 | } 137 | -------------------------------------------------------------------------------- /src/tui/state_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::context::Context; 2 | use crate::settings::Units; 3 | use crate::Settings; 4 | use std::sync::{Arc, Mutex}; 5 | 6 | #[derive(Debug, Clone, PartialEq)] 7 | pub enum ForecastMode { 8 | Daily, // 7-day forecast 9 | Hourly, // 24-hour forecast 10 | } 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct TuiState { 14 | pub context: Context, 15 | pub settings: Settings, 16 | pub loading: bool, 17 | pub last_fetch_time: u64, 18 | pub weather_created_at: u64, 19 | pub currently_selected_location: String, 20 | pub forecast_mode: ForecastMode, 21 | } 22 | 23 | pub struct TuiStateManager { 24 | pub state: Arc>, 25 | } 26 | 27 | impl TuiStateManager { 28 | pub fn new(context: Context, settings: Settings) -> Self { 29 | let now = crate::utils::get_now(); 30 | let weather_created_at = now - context.cache_age; 31 | 32 | // Determine the initial currently selected location 33 | let currently_selected_location = if settings.location.is_empty() { 34 | "Automatic".to_string() 35 | } else { 36 | format!("{}, {}", context.city, context.country) 37 | }; 38 | 39 | let initial_state = TuiState { 40 | context: context.clone(), 41 | settings, 42 | loading: false, 43 | last_fetch_time: weather_created_at, 44 | weather_created_at, 45 | currently_selected_location, 46 | forecast_mode: ForecastMode::Daily, 47 | }; 48 | let state = Arc::new(Mutex::new(initial_state)); 49 | Self { state } 50 | } 51 | 52 | pub fn get_state(&self) -> Arc> { 53 | self.state.clone() 54 | } 55 | 56 | pub fn set_loading(&self, loading: bool) { 57 | let mut state_guard = self.state.lock().unwrap(); 58 | state_guard.loading = loading; 59 | } 60 | 61 | pub fn update_context(&self, context: Context) { 62 | let mut state_guard = self.state.lock().unwrap(); 63 | let now = crate::utils::get_now(); 64 | let weather_created_at = now - context.cache_age; 65 | 66 | state_guard.context = context; 67 | state_guard.loading = false; 68 | state_guard.last_fetch_time = now; 69 | state_guard.weather_created_at = weather_created_at; 70 | } 71 | 72 | pub fn update_context_with_location(&self, context: Context, location: String) { 73 | let mut state_guard = self.state.lock().unwrap(); 74 | let now = crate::utils::get_now(); 75 | let weather_created_at = now - context.cache_age; 76 | 77 | state_guard.context = context; 78 | state_guard.loading = false; 79 | state_guard.last_fetch_time = now; 80 | state_guard.weather_created_at = weather_created_at; 81 | state_guard.currently_selected_location = location; 82 | } 83 | 84 | pub fn get_current_location(&self) -> String { 85 | let state_guard = self.state.lock().unwrap(); 86 | state_guard.currently_selected_location.clone() 87 | } 88 | 89 | pub fn toggle_units(&self) -> Units { 90 | let mut state_guard = self.state.lock().unwrap(); 91 | state_guard.settings.units = match state_guard.settings.units { 92 | Units::Metric => Units::Imperial, 93 | Units::Imperial => Units::Metric, 94 | }; 95 | state_guard.settings.units.clone() 96 | } 97 | 98 | pub fn get_settings(&self) -> Settings { 99 | let state_guard = self.state.lock().unwrap(); 100 | state_guard.settings.clone() 101 | } 102 | 103 | pub fn needs_refresh(&self) -> bool { 104 | let state_guard = self.state.lock().unwrap(); 105 | let now = crate::utils::get_now(); 106 | now - state_guard.last_fetch_time > super::constants::WEATHER_CACHE_DURATION 107 | } 108 | 109 | pub fn is_loading(&self) -> bool { 110 | let state_guard = self.state.lock().unwrap(); 111 | state_guard.loading 112 | } 113 | 114 | pub fn get_context(&self) -> Context { 115 | let state_guard = self.state.lock().unwrap(); 116 | state_guard.context.clone() 117 | } 118 | 119 | pub fn update_cache_age(&self) { 120 | let mut state_guard = self.state.lock().unwrap(); 121 | let now = crate::utils::get_now(); 122 | state_guard.context.cache_age = now - state_guard.weather_created_at; 123 | } 124 | 125 | pub fn toggle_forecast_mode(&self) -> ForecastMode { 126 | let mut state_guard = self.state.lock().unwrap(); 127 | state_guard.forecast_mode = match state_guard.forecast_mode { 128 | ForecastMode::Daily => ForecastMode::Hourly, 129 | ForecastMode::Hourly => ForecastMode::Daily, 130 | }; 131 | state_guard.forecast_mode.clone() 132 | } 133 | 134 | pub fn get_forecast_mode(&self) -> ForecastMode { 135 | let state_guard = self.state.lock().unwrap(); 136 | state_guard.forecast_mode.clone() 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/tui/location_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::api::location::LocationData; 2 | use crate::utils::cache; 3 | use cursive::views::SelectView; 4 | use savefile::prelude::*; 5 | use savefile_derive::Savefile; 6 | use serde::{Deserialize, Serialize}; 7 | use std::sync::{Arc, Mutex}; 8 | 9 | #[derive(Serialize, Deserialize, Debug, Default, Savefile)] 10 | pub struct LocationList { 11 | pub locations: Vec, 12 | } 13 | 14 | impl LocationList { 15 | pub fn load() -> Self { 16 | let filename = cache::get_cached_file("locations", "list"); 17 | load_file(&filename, 0).unwrap_or_default() 18 | } 19 | 20 | pub fn save(&self) { 21 | let filename = cache::get_cached_file("locations", "list"); 22 | if let Err(e) = save_file(&filename, 0, self) { 23 | eprintln!("Unable to save location list: {e:#?}"); 24 | } 25 | } 26 | 27 | pub fn add_location(&mut self, location: String) { 28 | if !self.locations.contains(&location) { 29 | self.locations.push(location); 30 | self.save(); 31 | } 32 | } 33 | 34 | pub fn remove_location_by_name(&mut self, location: &str) { 35 | if let Some(index) = self.locations.iter().position(|loc| loc == location) { 36 | self.locations.remove(index); 37 | self.save(); 38 | } 39 | } 40 | 41 | /// Returns a sorted list of locations and the index of the specified location 42 | pub fn get_sorted_locations_with_index(&self, target_location: &str) -> (Vec, Option) { 43 | let (sorted_locations, _) = self.get_sorted_locations(); 44 | let target_index = sorted_locations.iter().position(|loc| loc == target_location); 45 | (sorted_locations, target_index) 46 | } 47 | 48 | /// Returns sorted locations with "Automatic" first, then alphabetically by city/country 49 | pub fn get_sorted_locations(&self) -> (Vec, Vec) { 50 | // Separate "Automatic" from other locations 51 | let mut automatic_locations = Vec::new(); 52 | let mut other_locations = Vec::new(); 53 | 54 | for location in &self.locations { 55 | if location == "Automatic" { 56 | automatic_locations.push(location.clone()); 57 | } else { 58 | other_locations.push(location.clone()); 59 | } 60 | } 61 | 62 | // Sort other locations by city, then country code 63 | other_locations.sort_by(|a, b| { 64 | let a_parts: Vec<&str> = a.split(',').collect(); 65 | let b_parts: Vec<&str> = b.split(',').collect(); 66 | 67 | if a_parts.len() >= 2 && b_parts.len() >= 2 { 68 | let a_city = a_parts[0].trim(); 69 | let a_country = a_parts[1].trim(); 70 | let b_city = b_parts[0].trim(); 71 | let b_country = b_parts[1].trim(); 72 | 73 | // Sort by city first, then by country 74 | a_city.cmp(b_city).then(a_country.cmp(b_country)) 75 | } else { 76 | // Fallback to string comparison for malformed entries 77 | a.cmp(b) 78 | } 79 | }); 80 | 81 | // Create the ordered list 82 | let mut all_ordered_locations = automatic_locations.clone(); 83 | all_ordered_locations.extend(other_locations.clone()); 84 | 85 | (all_ordered_locations, other_locations) 86 | } 87 | } 88 | 89 | #[derive(Clone)] 90 | pub struct LocationManager { 91 | location_list: Arc>, 92 | } 93 | 94 | impl Default for LocationManager { 95 | fn default() -> Self { 96 | Self::new() 97 | } 98 | } 99 | 100 | impl LocationManager { 101 | pub fn new() -> Self { 102 | let location_list = Arc::new(Mutex::new(LocationList::load())); 103 | Self { location_list } 104 | } 105 | 106 | pub fn get_location_list(&self) -> Arc> { 107 | self.location_list.clone() 108 | } 109 | 110 | pub fn add_location(&self, location: String) { 111 | let mut list = self.location_list.lock().unwrap(); 112 | list.add_location(location); 113 | } 114 | 115 | pub fn remove_location_by_name(&self, location: &str) { 116 | let mut list = self.location_list.lock().unwrap(); 117 | list.remove_location_by_name(location); 118 | } 119 | 120 | pub fn rebuild_select_view(&self, view: &mut SelectView, target_location: &str) -> Option { 121 | let list = self.location_list.lock().unwrap(); 122 | let (sorted_locations, target_index) = list.get_sorted_locations_with_index(target_location); 123 | 124 | // Clear and rebuild the SelectView with sorted locations 125 | view.clear(); 126 | for location in &sorted_locations { 127 | view.add_item(location.clone(), location.clone()); 128 | } 129 | 130 | target_index 131 | } 132 | 133 | pub fn get_current_location_string(&self, settings_location: &str) -> String { 134 | if settings_location.is_empty() { 135 | "Automatic".to_string() 136 | } else { 137 | LocationData::normalize_location_string(settings_location) 138 | } 139 | } 140 | 141 | pub fn ensure_location_in_list(&self, location: String) { 142 | let mut list = self.location_list.lock().unwrap(); 143 | if !list.locations.contains(&location) { 144 | list.add_location(location); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/utils/mappings.rs: -------------------------------------------------------------------------------- 1 | const COMPASS_DIRECTIONS: [&str; 9] = ["N", "NE", "E", "SE", "S", "SW", "W", "NW", "N"]; 2 | const COMPASS_SEGMENT: f64 = 45.0; 3 | 4 | /// Converts wind direction degrees to compass direction string. 5 | /// 6 | /// Takes a wind direction in degrees (0-360) and returns the corresponding 7 | /// compass direction (N, NE, E, SE, S, SW, W, NW). The function handles 8 | /// wraparound and uses 45-degree segments for each direction. 9 | /// 10 | /// # Arguments 11 | /// 12 | /// * `degrees` - Wind direction in degrees (0-360, where 0/360 = North) 13 | /// 14 | /// # Returns 15 | /// 16 | /// Returns a string representing the compass direction, or "??" if invalid. 17 | pub fn degrees2compass(degrees: f64) -> String { 18 | let normalized = degrees % 360.0; 19 | let index = ((normalized) / COMPASS_SEGMENT).round() as i32; 20 | 21 | match COMPASS_DIRECTIONS.get(index as usize) { 22 | Some(&direction) => direction, 23 | None => "??", 24 | } 25 | .to_string() 26 | } 27 | 28 | /// Converts a weather code to a weather icon string. 29 | /// 30 | /// Takes a weather code from the Open-Meteo API and returns the corresponding 31 | /// Unicode weather icon. This is a convenience function that combines 32 | /// weather code mapping with icon lookup. 33 | /// 34 | /// # Arguments 35 | /// 36 | /// * `code` - Weather code from Open-Meteo API 37 | /// 38 | /// # Returns 39 | /// 40 | /// Returns a Unicode string representing the weather icon. 41 | pub fn weather_code2icon(code: i32) -> String { 42 | openweather_weather_icon(meteo2openweather_codes(code)) 43 | } 44 | 45 | /// Maps OpenWeather API condition codes to Unicode weather icons. 46 | /// 47 | /// Takes an OpenWeather condition code (e.g., "01d", "10n") and returns 48 | /// the corresponding Unicode weather icon. Supports both day and night 49 | /// variants for most conditions. 50 | /// 51 | /// # Arguments 52 | /// 53 | /// * `condition` - OpenWeather condition code string 54 | /// 55 | /// # Returns 56 | /// 57 | /// Returns a Unicode weather icon string, or empty string if unknown. 58 | pub fn openweather_weather_icon(condition: String) -> String { 59 | match condition.as_str() { 60 | "01d" => "󰖙", 61 | "01n" => "󰖔", 62 | "02d" | "02n" => "󰖕", 63 | "03d" | "03n" => "󰖐", 64 | "04d" | "04n" => "󰖐", 65 | "09d" | "09n" => "󰖗", 66 | "10d" | "10n" => "󰖖", 67 | "11d" | "11n" => "󰖓", 68 | "13d" | "13n" => "󰼶", 69 | "50d" | "50n" => "", 70 | _ => "", 71 | } 72 | .to_string() 73 | } 74 | 75 | /// Converts Open-Meteo weather codes to OpenWeather API equivalent codes. 76 | /// 77 | /// Maps weather codes from the Open-Meteo API to their OpenWeather API 78 | /// equivalents for consistent icon and description handling. This enables 79 | /// using established OpenWeather icon sets with Open-Meteo data. 80 | /// 81 | /// # Arguments 82 | /// 83 | /// * `code` - Weather code from Open-Meteo API 84 | /// 85 | /// # Returns 86 | /// 87 | /// Returns the equivalent OpenWeather condition code, or "unknown" if unmapped. 88 | pub fn meteo2openweather_codes(code: i32) -> String { 89 | match code { 90 | 0 => "01d", // Clear sky 91 | 1 => "02d", // Mainly clear 92 | 2 => "03d", // Partly cloudy 93 | 3 => "04d", // Overcast 94 | 45 => "50d", // Fog 95 | 48 => "50d", // Depositing rime fog 96 | 51 => "09d", // Drizzle, light 97 | 53 => "09d", // Drizzle, moderate 98 | 55 => "09d", // Drizzle, dense 99 | 56 => "09n", // Freezing drizzle, light 100 | 57 => "09n", // Freezing drizzle, dense 101 | 61 => "10d", // Rain, slight 102 | 63 => "10d", // Rain, moderate 103 | 65 => "10d", // Rain, heavy 104 | 66 => "13n", // Freezing rain, light 105 | 67 => "13n", // Freezing rain, heavy 106 | 71 => "13d", // Snow fall, slight 107 | 73 => "13d", // Snow fall, moderate 108 | 75 => "13d", // Snow fall, heavy 109 | 77 => "13d", // Snow grains 110 | 80 => "09d", // Rain showers, slight 111 | 81 => "09d", // Rain showers, moderate or heavy 112 | 82 => "09d", // Heavy rain showers 113 | 85 => "13n", // Snow showers slight to moderate 114 | 86 => "13n", // Snow showers heavy 115 | 95 => "11d", // Thunderstorm 116 | 96 | 99 => "11n", // Thunderstorm with hail 117 | _ => "unknown", 118 | } 119 | .to_string() 120 | } 121 | 122 | /// Converts a weather code to a human-readable description. 123 | /// 124 | /// Takes a weather code from the Open-Meteo API and returns a descriptive 125 | /// string explaining the weather condition in plain English. 126 | /// 127 | /// # Arguments 128 | /// 129 | /// * `code` - Weather code from Open-Meteo API 130 | /// 131 | /// # Returns 132 | /// 133 | /// Returns a human-readable weather description string. 134 | pub fn weather_description(code: i32) -> String { 135 | match code { 136 | 0 => "Clear sky", 137 | 1 => "Mainly clear", 138 | 2 => "Partly cloudy", 139 | 3 => "Overcast", 140 | 45 => "Fog", 141 | 48 => "Depositing rime fog", 142 | 51 => "Drizzle, light", 143 | 53 => "Drizzle, moderate", 144 | 55 => "Drizzle, dense", 145 | 56 => "Freezing drizzle, light", 146 | 57 => "Freezing drizzle, dense", 147 | 61 => "Rain, slight", 148 | 63 => "Rain, moderate", 149 | 65 => "Rain, heavy", 150 | 66 => "Freezing rain, light", 151 | 67 => "Freezing rain, heavy", 152 | 71 => "Snow fall, slight", 153 | 73 => "Snow fall, moderate", 154 | 75 => "Snow fall, heavy", 155 | 77 => "Snow grains", 156 | 80 => "Rain showers, slight", 157 | 81 => "Rain showers, moderate or heavy", 158 | 82 => "Heavy rain showers", 159 | 85 => "Snow showers slight to moderate", 160 | 86 => "Snow showers heavy", 161 | 95 => "Thunderstorm", 162 | 96 | 99 => "Thunderstorm with hail", 163 | _ => "Unknown weather code", 164 | } 165 | .to_string() 166 | } 167 | -------------------------------------------------------------------------------- /src/utils/weather_classification.rs: -------------------------------------------------------------------------------- 1 | /// Weather classification utilities for categorizing weather conditions by code ranges. 2 | 3 | #[derive(Debug, Clone, PartialEq)] 4 | pub enum WeatherCondition { 5 | Fog, 6 | Snow, 7 | Rain, 8 | Clear, 9 | } 10 | 11 | /// Classifies weather conditions based on weather codes. 12 | /// 13 | /// This function maps weather codes to general categories used for styling 14 | /// and conditional display logic throughout the application. 15 | /// 16 | /// # Arguments 17 | /// 18 | /// * `weather_code` - The weather code to classify (typically from Open-Meteo API) 19 | /// 20 | /// # Returns 21 | /// 22 | /// Returns a `WeatherCondition` enum representing the general weather category. 23 | /// 24 | /// # Weather Code Ranges 25 | /// 26 | /// - **Fog**: 40-49 (various fog conditions) 27 | /// - **Snow**: 70-79 (snow, snow showers, etc.) 28 | /// - **Rain**: 50-69, 80-99 (rain, drizzle, thunderstorms) 29 | /// - **Clear**: All other codes (clear, partly cloudy, overcast) 30 | pub fn classify_weather(weather_code: i32) -> WeatherCondition { 31 | match weather_code { 32 | 40..=49 => WeatherCondition::Fog, 33 | 70..=79 => WeatherCondition::Snow, 34 | 50..=69 | 80..=99 => WeatherCondition::Rain, 35 | _ => WeatherCondition::Clear, 36 | } 37 | } 38 | 39 | /// Checks if the weather condition involves precipitation (rain or snow). 40 | /// 41 | /// # Arguments 42 | /// 43 | /// * `weather_code` - The weather code to check 44 | /// 45 | /// # Returns 46 | /// 47 | /// Returns `true` if the weather involves precipitation, `false` otherwise. 48 | pub fn has_precipitation(weather_code: i32) -> bool { 49 | matches!(classify_weather(weather_code), WeatherCondition::Rain | WeatherCondition::Snow) 50 | } 51 | 52 | /// Gets the CSS class name for a weather condition (used in Waybar output). 53 | /// 54 | /// # Arguments 55 | /// 56 | /// * `weather_code` - The weather code to get the class for 57 | /// 58 | /// # Returns 59 | /// 60 | /// Returns an `Option` with the CSS class name, or `None` for clear conditions. 61 | pub fn get_weather_css_class(weather_code: i32) -> Option { 62 | match classify_weather(weather_code) { 63 | WeatherCondition::Fog => Some("fog".to_string()), 64 | WeatherCondition::Snow => Some("snow".to_string()), 65 | WeatherCondition::Rain => Some("rain".to_string()), 66 | WeatherCondition::Clear => None, 67 | } 68 | } 69 | 70 | /// Gets all applicable CSS classes for weather and temperature conditions. 71 | /// 72 | /// This function combines weather condition classes with temperature-based classes 73 | /// to provide a complete set of CSS classes for styling weather displays. 74 | /// 75 | /// # Arguments 76 | /// 77 | /// * `weather_code` - The weather code to classify 78 | /// * `temperature` - The current temperature 79 | /// * `hot_threshold` - Optional temperature threshold for "hot" class 80 | /// * `cold_threshold` - Optional temperature threshold for "cold" class 81 | /// 82 | /// # Returns 83 | /// 84 | /// Returns a `Vec` containing all applicable CSS class names. 85 | pub fn get_all_weather_css_classes( 86 | weather_code: i32, 87 | temperature: f64, 88 | hot_threshold: Option, 89 | cold_threshold: Option, 90 | ) -> Vec { 91 | let mut classes = Vec::new(); 92 | 93 | // Add temperature-based classes 94 | if let Some(hot_temp) = hot_threshold { 95 | if temperature > hot_temp { 96 | classes.push("hot".to_string()); 97 | } 98 | } 99 | 100 | if let Some(cold_temp) = cold_threshold { 101 | if temperature < cold_temp { 102 | classes.push("cold".to_string()); 103 | } 104 | } 105 | 106 | // Add weather condition class 107 | if let Some(weather_class) = get_weather_css_class(weather_code) { 108 | classes.push(weather_class); 109 | } 110 | 111 | classes 112 | } 113 | 114 | #[cfg(test)] 115 | mod tests { 116 | use super::*; 117 | 118 | #[test] 119 | fn test_classify_weather() { 120 | assert_eq!(classify_weather(45), WeatherCondition::Fog); 121 | assert_eq!(classify_weather(75), WeatherCondition::Snow); 122 | assert_eq!(classify_weather(60), WeatherCondition::Rain); 123 | assert_eq!(classify_weather(85), WeatherCondition::Rain); 124 | assert_eq!(classify_weather(0), WeatherCondition::Clear); 125 | assert_eq!(classify_weather(30), WeatherCondition::Clear); 126 | } 127 | 128 | #[test] 129 | fn test_has_precipitation() { 130 | assert!(!has_precipitation(45)); // fog 131 | assert!(has_precipitation(75)); // snow 132 | assert!(has_precipitation(60)); // rain 133 | assert!(has_precipitation(85)); // rain 134 | assert!(!has_precipitation(0)); // clear 135 | } 136 | 137 | #[test] 138 | fn test_get_weather_css_class() { 139 | assert_eq!(get_weather_css_class(45), Some("fog".to_string())); 140 | assert_eq!(get_weather_css_class(75), Some("snow".to_string())); 141 | assert_eq!(get_weather_css_class(60), Some("rain".to_string())); 142 | assert_eq!(get_weather_css_class(0), None); 143 | } 144 | 145 | #[test] 146 | fn test_get_all_weather_css_classes() { 147 | // Test with hot temperature and rain 148 | let classes = get_all_weather_css_classes(60, 35.0, Some(30.0), Some(0.0)); 149 | assert_eq!(classes, vec!["hot", "rain"]); 150 | 151 | // Test with cold temperature and snow 152 | let classes = get_all_weather_css_classes(75, -5.0, Some(30.0), Some(0.0)); 153 | assert_eq!(classes, vec!["cold", "snow"]); 154 | 155 | // Test with normal temperature and clear weather 156 | let classes = get_all_weather_css_classes(0, 20.0, Some(30.0), Some(0.0)); 157 | assert!(classes.is_empty()); 158 | 159 | // Test with no thresholds 160 | let classes = get_all_weather_css_classes(45, 20.0, None, None); 161 | assert_eq!(classes, vec!["fog"]); 162 | 163 | // Test edge case: exactly at threshold 164 | let classes = get_all_weather_css_classes(60, 30.0, Some(30.0), Some(0.0)); 165 | assert_eq!(classes, vec!["rain"]); // Should not include "hot" for exactly at threshold 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | **Outside** is a CLI weather client written in Rust that fetches weather data from the Open-Meteo API and supports multiple output formats (simple, detailed, JSON, Waybar, and TUI). The application features intelligent caching, location detection, and customizable templating. 8 | 9 | ## Development Commands 10 | 11 | ### Build and Run 12 | ```bash 13 | # Build the project 14 | cargo build 15 | 16 | # Build for release 17 | cargo build --release 18 | 19 | # Run the application 20 | cargo run 21 | 22 | # Run with specific arguments 23 | cargo run -- --location "New York, US" --units imperial --output detailed 24 | 25 | # Run with TUI interface 26 | cargo run -- --location "London, GB" --output tui 27 | 28 | # Install from source 29 | cargo install --path . 30 | ``` 31 | 32 | ### Testing 33 | ```bash 34 | # Run tests (no test files currently exist in the codebase) 35 | cargo test 36 | 37 | # Run tests with output 38 | cargo test -- --nocapture 39 | ``` 40 | 41 | ### Linting and Formatting 42 | ```bash 43 | # Format code 44 | cargo fmt 45 | 46 | # Check code without building 47 | cargo check 48 | 49 | # Run clippy for linting 50 | cargo clippy 51 | ``` 52 | 53 | ### Release Management 54 | ```bash 55 | # Generate changelog (uses git-cliff) 56 | git cliff 57 | 58 | # Create release with git-cliff 59 | git cliff --tag v 60 | ``` 61 | 62 | ## Code Architecture 63 | 64 | ### Core Application Flow 65 | 1. **Configuration Loading**: Settings are loaded from `~/.config/outside/config.yaml` and CLI arguments via `cli-settings-derive` 66 | 2. **Location Resolution**: Auto-detection via IP geolocation or explicit city/country parsing 67 | 3. **Weather Data Fetching**: Open-Meteo API calls with intelligent caching (10 minutes for weather, 4 hours for location) 68 | 4. **Context Building**: Raw API data is transformed into a unified context structure 69 | 5. **Output Rendering**: Template-based rendering using `tinytemplate` with trait-based output formatters 70 | 71 | ### Module Structure 72 | 73 | - **`src/main.rs`**: Application entry point, coordinates the full pipeline 74 | - **`src/settings.rs`**: Configuration management using `cli-settings-derive` for unified CLI/config file handling 75 | - **`src/api/`**: External API integrations 76 | - `mod.rs`: API module organization 77 | - `client.rs`: HTTP client utilities 78 | - `weather.rs`: Open-Meteo API client with caching 79 | - `location.rs`: Location data management and caching 80 | - `geolocation.rs`: City/country-based location lookup 81 | - `iplocation.rs`: IP-based location detection 82 | - **`src/context.rs`**: Central data transformation layer that converts raw API responses into a unified context structure 83 | - **`src/output/`**: Output formatting system 84 | - `mod.rs`: Trait-based output system with template helpers 85 | - `simple.rs`, `detailed.rs`, `json.rs`, `waybar.rs`: Format-specific renderers 86 | - **`src/tui/`**: Terminal User Interface components 87 | - `mod.rs`: TUI module organization and main Output trait implementation 88 | - `async_operations.rs`: Weather data fetching with background threads 89 | - `constants.rs`: TUI-specific constants and configuration 90 | - `keyboard_handlers.rs`: Keybinding management and user input handling 91 | - `location_manager.rs`: Location list management and caching 92 | - `state_manager.rs`: Application state management across TUI lifecycle 93 | - `ui_components.rs`: UI layout creation and theme configuration 94 | - `weather_display.rs`: Weather data presentation components 95 | - **`src/utils/`**: Utility functions 96 | - `cache.rs`: File-based caching with hashed filenames 97 | - `conversions.rs`: Date/time and data format conversions 98 | - `mappings.rs`: Weather code to icon/description mappings 99 | - `unitstrings.rs`: Metric/Imperial unit string management 100 | - `urls.rs`: URL construction utilities 101 | - `weather_classification.rs`: Weather condition categorization for styling and conditional logic 102 | 103 | ### Key Design Patterns 104 | 105 | 1. **Caching Strategy**: Uses `savefile` for binary serialization with location+units-based cache keys 106 | 2. **Template System**: Custom `TinyTemplate` formatters (e.g., `round` filter) for output rendering 107 | 3. **Trait-based Output**: Common `Output` trait enables polymorphic rendering across formats 108 | 4. **Error Handling**: Uses `anyhow` for error propagation with panic on critical failures 109 | 5. **Configuration**: Hybrid CLI/config file approach using `cli-settings-derive` 110 | 111 | ### Dependencies 112 | 113 | - **API Client**: `isahc` with JSON features 114 | - **Serialization**: `serde` ecosystem (JSON, YAML) 115 | - **CLI**: `clap` with derive features 116 | - **Caching**: `savefile` for binary serialization 117 | - **Templates**: `tinytemplate` for output formatting 118 | - **Time**: `chrono` for date/time handling 119 | - **TUI**: `cursive` for terminal user interface 120 | 121 | ### Configuration Files 122 | 123 | - **User Config**: `~/.config/outside/config.yaml` for persistent settings 124 | - **Build Config**: `Cargo.toml` with conventional commit linting configuration 125 | - **Changelog**: `cliff.toml` configured for conventional commits and automated changelog generation 126 | 127 | ### Testing and Quality 128 | 129 | The project uses standard Rust tooling with cargo commands. Currently no test files exist in the codebase. When adding new functionality, consider adding appropriate tests. 130 | 131 | ### Notable Implementation Details 132 | 133 | - Weather data cached for 10 minutes, location data for 4 hours 134 | - Cache files stored in platform-specific cache directories with hashed filenames 135 | - Units enum affects both API parameters and cache keys 136 | - Template variables available via `outside -o json` for debugging 137 | - Waybar integration includes conditional CSS classes for weather conditions 138 | - Streaming mode available with `--stream` for continuous output updates (not compatible with TUI mode) 139 | - Configuration supports custom templates for simple and waybar outputs 140 | - TUI mode provides an interactive terminal interface with: 141 | - Location list management on the right side (cached using existing mechanism) 142 | - Dynamic weather updates: switching locations fetches and displays new weather data 143 | - Loading indicators during weather data fetching 144 | - Background threads for non-blocking API calls 145 | - Terminal-native theming: uses your terminal's default colors and transparency 146 | - Confirmation dialogs for location deletion 147 | - Keybinds: 'a' to add location, 'd' to delete, Enter to switch, 'u' to toggle units, q/Esc to quit 148 | - Full terminal layout with weather display on left, location list on right -------------------------------------------------------------------------------- /src/tui/weather_display.rs: -------------------------------------------------------------------------------- 1 | use crate::context::Context; 2 | use crate::utils::weather_classification; 3 | 4 | pub struct WeatherDisplay; 5 | 6 | impl WeatherDisplay { 7 | pub fn format_header_text(context: &Context) -> String { 8 | format!( 9 | "{}, {}\n\ 10 | {} {}{} • {} • Feels like {}{}", 11 | context.city, 12 | context.country, 13 | context.weather_icon, 14 | context.temperature.round(), 15 | context.temperature_unit, 16 | context.weather_description, 17 | context.feels_like.round(), 18 | context.temperature_unit 19 | ) 20 | } 21 | 22 | pub fn format_current_info(context: &Context) -> String { 23 | let mut info = format!( 24 | "Temperature: {}{}\n\ 25 | Humidity: {}%\n\ 26 | Pressure: {} hPa\n\ 27 | Wind: {} {} with gusts up to {} {} ({})\n\ 28 | UV Index: {}\n\ 29 | Precipitation: {} {} ({}% chance)", 30 | context.temperature.round(), 31 | context.temperature_unit, 32 | context.humidity, 33 | context.pressure, 34 | context.wind_speed.round(), 35 | context.wind_speed_unit, 36 | context.wind_gusts.round(), 37 | context.wind_speed_unit, 38 | context.wind_compass, 39 | context.uv_index, 40 | context.precipitation_sum, 41 | context.precipitation_unit, 42 | context.precipitation_chance 43 | ); 44 | 45 | // Add precipitation timing if available 46 | if let Some(description) = &context.precipitation_description { 47 | info.push('\n'); 48 | info.push_str(&format!(" {description}")); 49 | } 50 | 51 | info.push_str(&format!("\nSun: {} • {}", context.sunrise, context.sunset)); 52 | 53 | info 54 | } 55 | 56 | pub fn format_hourly_forecast(context: &Context) -> String { 57 | // Calculate available width: assume 80 chars wide terminal minus location panel 58 | let available_width = Self::calculate_available_forecast_width(); 59 | Self::format_hourly_forecast_with_width(context, available_width) 60 | } 61 | 62 | fn calculate_available_forecast_width() -> usize { 63 | // Get actual terminal width using termsize crate 64 | let terminal_width: usize = match termsize::get() { 65 | Some(size) => size.cols as usize, 66 | None => 120, // Fallback for wide terminals if detection fails 67 | }; 68 | 69 | // Account for location panel and margins 70 | // Location panel: LOCATION_LIST_WIDTH (24) + borders/spacing (~6) 71 | // Weather panel borders: ~4 chars 72 | let location_panel_width: usize = crate::tui::constants::LOCATION_LIST_WIDTH + 6; 73 | let weather_panel_margins: usize = 4; 74 | let used_width = location_panel_width + weather_panel_margins; 75 | 76 | (terminal_width.saturating_sub(used_width)).max(40) // Minimum 40 chars 77 | } 78 | 79 | pub fn format_hourly_forecast_with_width(context: &Context, available_width: usize) -> String { 80 | let mut forecast_text = String::new(); 81 | 82 | // Fixed layout: 3 columns, 8 rows, but adjust cell width based on available space 83 | let num_cols = 3; 84 | let num_rows = 8; 85 | let col_spacing = 4; // Space between columns 86 | let total_spacing = (num_cols - 1) * col_spacing; 87 | let cell_width = (available_width.saturating_sub(total_spacing)) / num_cols; 88 | 89 | // Display 24 hours in 3 columns with 8 rows each 90 | for row in 0..num_rows { 91 | let mut line = String::new(); 92 | 93 | for col in 0..num_cols { 94 | let hour_index = col * num_rows + row; 95 | if hour_index < context.hourly.len() { 96 | let hour = &context.hourly[hour_index]; 97 | 98 | // Format: " 9am 󰖖 22°C 0.1mm ( 84%)" for 12-hour or "19:00 󰖖 22°C 0.1mm ( 84%)" for 24-hour 99 | let formatted_time = if hour.time.contains("am") || hour.time.contains("pm") { 100 | // 12-hour format: convert "09:00am" to " 9am" 101 | let time_without_zeros = hour.time.replace(":00", ""); 102 | if let Some(stripped) = time_without_zeros.strip_prefix('0') { 103 | format!(" {stripped}") 104 | } else { 105 | format!("{time_without_zeros:>4}") 106 | } 107 | } else { 108 | // 24-hour format: keep as is "19:00" 109 | format!("{:>5}", hour.time) 110 | }; 111 | 112 | let temp_unit = if context.temperature_unit.contains('F') { "F" } else { "C" }; 113 | let temp = format!("{:2}°{}", hour.temperature.round(), temp_unit); 114 | let precip = format!("{:4.1}{}", hour.precipitation, context.precipitation_unit); 115 | let prob = format!("{:3}%", hour.precipitation_probability); 116 | 117 | let cell_content = 118 | format!("{formatted_time} {} {temp} {precip} {prob}", hour.weather_icon); 119 | let padded_cell = format!("{cell_content: String { 143 | let mut forecast_text = String::new(); 144 | for (index, day) in context.forecast.iter().enumerate() { 145 | let display_date = if index == 0 { 146 | "Today".to_string() 147 | } else if index == 1 { 148 | "Tomorrow".to_string() 149 | } else { 150 | day.date.clone() 151 | }; 152 | let weather_description = if weather_classification::has_precipitation(day.weather_code) { 153 | format!("{} ({}%)", day.weather_description, day.precipitation_chance) 154 | } else { 155 | day.weather_description.clone() 156 | }; 157 | 158 | forecast_text.push_str(&format!( 159 | "{:10} {} {:>2}-{:<2}{} {}\n", 160 | display_date, 161 | day.weather_icon, 162 | day.temperature_low.round(), 163 | day.temperature_high.round(), 164 | context.temperature_unit, 165 | weather_description 166 | )); 167 | } 168 | forecast_text.push('\n'); 169 | forecast_text 170 | } 171 | 172 | pub fn format_loading_message() -> String { 173 | "Loading weather data...".to_string() 174 | } 175 | 176 | pub fn format_wait_message() -> String { 177 | "Please wait...".to_string() 178 | } 179 | 180 | pub fn format_units_switching_message() -> String { 181 | "Switching units...".to_string() 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/api/location.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::*; 2 | use crate::Settings; 3 | 4 | use crate::api::geolocation; 5 | use crate::api::iplocation; 6 | 7 | use anyhow::Result; 8 | use savefile::prelude::*; 9 | use savefile_derive::Savefile; 10 | use serde::{Deserialize, Serialize}; 11 | use stringcase; 12 | 13 | /// Trait for different location lookup methods. 14 | /// 15 | /// This trait abstracts the location lookup functionality, allowing for different 16 | /// implementations such as city-based geocoding or IP-based location detection. 17 | pub trait Location { 18 | /// Fetches location data for the specified name and country code. 19 | /// 20 | /// # Arguments 21 | /// 22 | /// * `name` - The city or location name to look up 23 | /// * `country_code` - The country code (may be empty for IP-based lookup) 24 | /// 25 | /// # Returns 26 | /// 27 | /// Returns `LocationData` containing coordinates and location information. 28 | /// 29 | /// # Errors 30 | /// 31 | /// Returns an error if the location cannot be found or API request fails. 32 | fn fetch(name: &str, country_code: &str) -> Result; 33 | } 34 | 35 | #[derive(Default, Deserialize, Serialize, Debug, Savefile)] 36 | pub struct LocationData { 37 | pub city: String, 38 | pub country_code: String, 39 | pub latitude: f64, 40 | pub longitude: f64, 41 | pub location: String, 42 | pub created_at: u64, 43 | } 44 | 45 | impl LocationData { 46 | /// Normalizes a city name to Title Case format using stringcase. 47 | /// 48 | /// This helper function converts city names to consistent Title Case format: 49 | /// - "new york" -> "New York" 50 | /// - "los angeles" -> "Los Angeles" 51 | /// - "san francisco" -> "San Francisco" 52 | /// 53 | /// # Arguments 54 | /// 55 | /// * `city` - The city name to normalize 56 | /// 57 | /// # Returns 58 | /// 59 | /// The normalized city name in Title Case format 60 | pub fn normalize_city_name(city: &str) -> String { 61 | // Convert to lowercase first, then capitalize each word using stringcase 62 | // Since stringcase doesn't have title_case, we'll manually split and capitalize 63 | city.split_whitespace().map(stringcase::pascal_case).collect::>().join(" ") 64 | } 65 | 66 | /// Normalizes a location string to consistent "City, COUNTRY" format. 67 | /// 68 | /// This helper function takes a location string and returns it in normalized format: 69 | /// - City names are converted to CamelCase 70 | /// - Country codes are converted to uppercase 71 | /// 72 | /// # Arguments 73 | /// 74 | /// * `location` - The location string to normalize (e.g., "new york, us") 75 | /// 76 | /// # Returns 77 | /// 78 | /// The normalized location string (e.g., "New York, US") 79 | pub fn normalize_location_string(location: &str) -> String { 80 | if location.is_empty() { 81 | return location.to_string(); 82 | } 83 | 84 | let parts: Vec<&str> = location.split(',').collect(); 85 | if parts.len() == 2 { 86 | let city = parts[0].trim(); 87 | let country = parts[1].trim().to_uppercase(); 88 | let normalized_city = Self::normalize_city_name(city); 89 | format!("{normalized_city}, {country}") 90 | } else { 91 | location.to_string() 92 | } 93 | } 94 | 95 | /// Normalizes the location data by formatting city as CamelCase and country as uppercase. 96 | /// 97 | /// This method ensures consistent formatting across all location data: 98 | /// - City names are converted to CamelCase (e.g., "new york" -> "New York") 99 | /// - Country codes are converted to uppercase (e.g., "us" -> "US") 100 | /// - The location string is updated to reflect the normalized format 101 | /// 102 | /// # Examples 103 | /// 104 | /// ``` 105 | /// let mut location = LocationData { 106 | /// city: "new york".to_string(), 107 | /// country_code: "us".to_string(), 108 | /// location: "new york, us".to_string(), 109 | /// ..Default::default() 110 | /// }; 111 | /// location.normalize(); 112 | /// assert_eq!(location.city, "New York"); 113 | /// assert_eq!(location.country_code, "US"); 114 | /// assert_eq!(location.location, "New York, US"); 115 | /// ``` 116 | pub fn normalize(&mut self) { 117 | // Convert city to CamelCase 118 | self.city = Self::normalize_city_name(&self.city); 119 | 120 | // Convert country code to uppercase 121 | self.country_code = self.country_code.to_uppercase(); 122 | 123 | // Update location string with normalized format 124 | if !self.city.is_empty() && !self.country_code.is_empty() { 125 | self.location = format!("{}, {}", self.city, self.country_code); 126 | } 127 | } 128 | 129 | /// Retrieves location data using cached data if available. 130 | /// 131 | /// Location data is cached for 4 hours (14400 seconds) to reduce API calls. 132 | /// If cached data is found for the same location and is still fresh, it will be returned. 133 | /// Otherwise, fresh data will be fetched using the appropriate lookup method. 134 | /// 135 | /// # Arguments 136 | /// 137 | /// * `s` - Settings containing location string and units for cache key generation 138 | /// 139 | /// # Returns 140 | /// 141 | /// Returns location data on success, or an error if lookup fails. 142 | /// 143 | /// # Errors 144 | /// 145 | /// This function will return an error if: 146 | /// - The location format is invalid (for manual location entry) 147 | /// - The API request fails 148 | /// - No location results are found 149 | pub fn get_cached(s: Settings) -> Result { 150 | let filename = cache::get_cached_file("location", &s.location); 151 | let now = get_now(); 152 | 153 | let fd: LocationData = load_file(&filename, 0).unwrap_or_default(); 154 | let l = s.location.to_owned(); 155 | 156 | // Normalize the input location for cache comparison 157 | let normalized_input = Self::normalize_location_string(&l); 158 | 159 | // Cache lifetime is 4 hours (14400 seconds) 160 | if fd.location == normalized_input && fd.created_at > 0 && now - fd.created_at < 14400 { 161 | return Ok(fd); 162 | } 163 | 164 | let mut data = Self::lookup(l)?; 165 | data.latitude = format!("{:.1}", data.latitude).parse().unwrap_or(0.0); 166 | data.longitude = format!("{:.1}", data.longitude).parse().unwrap_or(0.0); 167 | 168 | match save_file(&filename, 0, &data) { 169 | Ok(_) => {}, 170 | Err(e) => eprintln!("Unable to save location data to disk: {e:#?}"), 171 | } 172 | 173 | Ok(data) 174 | } 175 | 176 | /// Looks up location data based on the provided location string. 177 | /// 178 | /// If the location string is empty, uses IP-based location detection. 179 | /// If the location string contains a comma, treats it as "City, CountryCode" format 180 | /// and uses geocoding API. 181 | /// 182 | /// # Arguments 183 | /// 184 | /// * `l` - Location string, either empty (for IP lookup) or "City, CountryCode" format 185 | /// 186 | /// # Returns 187 | /// 188 | /// Returns location data on success, or an error if the lookup fails. 189 | /// 190 | /// # Errors 191 | /// 192 | /// Returns an error if: 193 | /// - The location format is invalid (not "City, CountryCode") 194 | /// - The geocoding or IP location API request fails 195 | /// - No results are found for the specified location 196 | fn lookup(l: String) -> Result { 197 | if !l.is_empty() { 198 | let parts: Vec<&str> = l.split(',').collect(); 199 | if parts.len() == 2 { 200 | let name = parts[0].trim(); 201 | let country_code = parts[1].trim().to_uppercase(); 202 | 203 | // Normalize the city name to CamelCase for consistency 204 | let normalized_name = Self::normalize_city_name(name); 205 | 206 | geolocation::GeoLocation::fetch(&normalized_name, &country_code) 207 | } else { 208 | Err(anyhow::anyhow!("Invalid location format. Use 'City, CountryCode'.")) 209 | } 210 | } else { 211 | iplocation::IPLocation::fetch("", "") 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/tui/ui_components.rs: -------------------------------------------------------------------------------- 1 | use crate::api::location::LocationData; 2 | use crate::tui::constants::*; 3 | use crate::tui::location_manager::LocationManager; 4 | use crate::tui::state_manager::{ForecastMode, TuiStateManager}; 5 | use crate::tui::weather_display::WeatherDisplay; 6 | use crate::Settings; 7 | use cursive::align::HAlign; 8 | use cursive::theme::{Color, ColorType, PaletteColor, Theme}; 9 | use cursive::view::{Nameable, Resizable}; 10 | use cursive::views::{DummyView, LinearLayout, Panel, ProgressBar, ResizedView, SelectView, TextView}; 11 | use cursive::Cursive; 12 | 13 | pub struct UiComponents; 14 | 15 | impl UiComponents { 16 | pub fn setup_theme(siv: &mut Cursive) { 17 | let mut theme = Theme::default(); 18 | theme.palette[PaletteColor::Background] = Color::TerminalDefault; 19 | theme.palette[PaletteColor::View] = Color::TerminalDefault; 20 | theme.palette[PaletteColor::Primary] = Color::TerminalDefault; 21 | theme.palette[PaletteColor::Secondary] = Color::TerminalDefault; 22 | theme.palette[PaletteColor::Tertiary] = Color::TerminalDefault; 23 | theme.palette[PaletteColor::TitlePrimary] = Color::Dark(cursive::theme::BaseColor::Blue); 24 | theme.palette[PaletteColor::TitleSecondary] = Color::TerminalDefault; 25 | theme.palette[PaletteColor::Highlight] = Color::Dark(cursive::theme::BaseColor::Blue); 26 | theme.palette[PaletteColor::HighlightInactive] = Color::Rgb(30, 30, 40); 27 | theme.palette[PaletteColor::Shadow] = Color::TerminalDefault; 28 | theme.palette[PaletteColor::HighlightText] = Color::TerminalDefault; 29 | theme.shadow = false; 30 | siv.set_theme(theme); 31 | } 32 | 33 | pub fn create_main_layout( 34 | state_manager: &TuiStateManager, 35 | location_manager: &LocationManager, 36 | settings: &Settings, 37 | ) -> LinearLayout { 38 | let weather_layout = Self::create_weather_layout(state_manager); 39 | let location_select = Self::create_location_panel(location_manager, settings); 40 | let help_bar = Self::create_help_bar(); 41 | 42 | let main_content = LinearLayout::horizontal() 43 | .child(ResizedView::with_full_width(weather_layout)) 44 | .child(ResizedView::with_fixed_width(LOCATION_LIST_WIDTH, location_select)); 45 | 46 | LinearLayout::vertical().child(ResizedView::with_full_height(main_content)).child(help_bar) 47 | } 48 | 49 | pub fn create_weather_layout(state_manager: &TuiStateManager) -> LinearLayout { 50 | if state_manager.is_loading() { 51 | return LinearLayout::vertical().child( 52 | Panel::new(TextView::new(WeatherDisplay::format_loading_message()).h_align(HAlign::Left)) 53 | .title("Weather") 54 | .title_position(cursive::align::HAlign::Left), 55 | ); 56 | } 57 | 58 | let context = state_manager.get_context(); 59 | let header_text = WeatherDisplay::format_header_text(&context); 60 | let current_info = WeatherDisplay::format_current_info(&context); 61 | let forecast_text = WeatherDisplay::format_forecast_text(&context); 62 | 63 | LinearLayout::vertical() 64 | .child( 65 | Panel::new(TextView::new(header_text).h_align(HAlign::Center).with_name(WEATHER_HEADER_NAME)) 66 | .title(format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))) 67 | .title_position(cursive::align::HAlign::Left), 68 | ) 69 | .child(ResizedView::with_full_height( 70 | Panel::new( 71 | LinearLayout::vertical() 72 | .child(TextView::new(current_info).with_name(WEATHER_CURRENT_NAME)) 73 | .child(DummyView.full_height()) 74 | .child( 75 | ProgressBar::new() 76 | .max(100) 77 | .with_color(ColorType::Palette(PaletteColor::HighlightInactive)) 78 | .with_label(|_value, (_min, _max)| "".into()) 79 | .with_name(DATA_AGE_PROGRESS_NAME), 80 | ), 81 | ) 82 | .title("Current"), 83 | )) 84 | .child( 85 | Panel::new(TextView::new(forecast_text).with_name(WEATHER_FORECAST_NAME)).title("Forecast"), 86 | ) 87 | } 88 | 89 | pub fn create_location_panel( 90 | location_manager: &LocationManager, 91 | settings: &Settings, 92 | ) -> Panel>> { 93 | let mut select = SelectView::new(); 94 | 95 | // Ensure CLI location is in the location list for selection 96 | if !settings.location.is_empty() { 97 | let normalized_cli_location = LocationData::normalize_location_string(&settings.location); 98 | location_manager.ensure_location_in_list(normalized_cli_location); 99 | } 100 | 101 | // Get sorted locations 102 | let location_list = location_manager.get_location_list(); 103 | let list = location_list.lock().unwrap(); 104 | let (all_ordered_locations, _) = list.get_sorted_locations(); 105 | drop(list); 106 | 107 | // Add items to select view 108 | for location in &all_ordered_locations { 109 | select.add_item(location.clone(), location.clone()); 110 | } 111 | 112 | // Set current location as selected 113 | let current_location = location_manager.get_current_location_string(&settings.location); 114 | if let Some(index) = all_ordered_locations.iter().position(|loc| *loc == current_location) { 115 | select.set_selection(index); 116 | } 117 | 118 | Panel::new(select.with_name(LOCATION_LIST_NAME)).title("Bookmarks") 119 | } 120 | 121 | pub fn create_help_bar() -> TextView { 122 | let help_text = 123 | "Enter: Select | a: Add new | d: Delete | u: Units | f: Forecast | q/Esc: Quit"; 124 | TextView::new(help_text).h_align(HAlign::Center) 125 | } 126 | 127 | pub fn create_add_location_dialog(on_submit: F) -> cursive::views::Dialog 128 | where 129 | F: Fn(&mut cursive::Cursive, &str) + 'static + Send + Sync, 130 | { 131 | let edit_view = cursive::views::EditView::new() 132 | .content("") 133 | .on_submit(on_submit) 134 | .with_name(NEW_LOCATION_NAME) 135 | .fixed_width(30); 136 | 137 | cursive::views::Dialog::around(edit_view).title("Add Location (City, Country)") 138 | } 139 | 140 | pub fn create_delete_confirmation_dialog(location: &str) -> cursive::views::Dialog { 141 | cursive::views::Dialog::text(format!("Are you sure you want to delete '{location}'?")) 142 | .title("Confirm Deletion") 143 | } 144 | 145 | pub fn update_weather_display_components(siv: &mut Cursive, state_manager: &TuiStateManager) { 146 | if state_manager.is_loading() { 147 | return; 148 | } 149 | 150 | let context = state_manager.get_context(); 151 | let forecast_mode = state_manager.get_forecast_mode(); 152 | let header_text = WeatherDisplay::format_header_text(&context); 153 | let current_info = WeatherDisplay::format_current_info(&context); 154 | 155 | // Choose forecast content based on mode 156 | let forecast_text = match forecast_mode { 157 | ForecastMode::Daily => WeatherDisplay::format_forecast_text(&context), 158 | ForecastMode::Hourly => WeatherDisplay::format_hourly_forecast(&context), 159 | }; 160 | 161 | siv.call_on_name(WEATHER_HEADER_NAME, |view: &mut TextView| { 162 | view.set_content(header_text); 163 | }); 164 | siv.call_on_name(WEATHER_CURRENT_NAME, |view: &mut TextView| { 165 | view.set_content(current_info); 166 | }); 167 | siv.call_on_name(WEATHER_FORECAST_NAME, |view: &mut TextView| { 168 | view.set_content(forecast_text); 169 | }); 170 | 171 | // Update data age progress bar 172 | let cache_duration = crate::tui::constants::WEATHER_CACHE_DURATION; 173 | let progress_percentage = 174 | ((context.cache_age as f64 / cache_duration as f64) * 100.0).min(100.0) as usize; 175 | siv.call_on_name(DATA_AGE_PROGRESS_NAME, |view: &mut ProgressBar| { 176 | view.set_value(progress_percentage); 177 | }); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Outside 2 | 3 | ![demo](https://github.com/BaconIsAVeg/outside/blob/main/demo/demo.gif?raw=true) 4 | 5 | A multi-purpose weather client for your terminal. 6 | 7 | Usage: outside [OPTIONS] 8 | 9 | Options: 10 | -l, --location Location to fetch weather data for, 11 | leave blank to auto-detect using your IP address 12 | -u, --units Units of measurement [possible values: metric, imperial] 13 | -o, --output Display format [possible values: tui, simple, detailed, json, waybar] 14 | -s, --stream Enable streaming mode for continuous output 15 | -i, --interval Interval in seconds between streaming updates [default: 30] 16 | -h, --help Print help 17 | -V, --version Print version 18 | 19 | The `--location` should be a string with your city and country code, e.g. `London, GB` or `New York, US`. If this value is not provided, http://ip-api.com will be used to auto-detect your location based on your IP address. Location data is cached for 4 hours, and weather data is cached for 10 minutes to reduce API calls. 20 | 21 | ## Example Outputs 22 | 23 | ### Simple 24 | 25 | Overcast 18°C | Wind 713 | Precipitation 53% 26 | 27 | ### Detailed 28 | 29 | 30 | $ outside -o detailed 31 | Edmonton, CA 32 | Current: 17.6°C Overcast 33 | Feels Like: 17.5°C 34 | Humidity: 72% 35 | Pressure: 1006.9hPa 36 | Wind: 6.6km/h with gusts up to 13.0km/h (W) 37 | UV Index: 6.2 38 | Precip: 0.8 mm (53% chance) 39 | Sunrise: 05:07am 40 | Sunset: 10:06pm 41 | 42 | Fri 06/27 9-22°C - Rain showers, slight 43 | Sat 06/28 13-21°C - Thunderstorm 44 | Sun 06/29 11-24°C - Overcast 45 | Mon 06/30 14-25°C - Overcast 46 | Tue 07/01 15-25°C - Overcast 47 | Wed 07/02 14-30°C - Overcast 48 | Thu 07/03 16-24°C - Rain showers, slight 49 | 50 | 51 | $ outside -o detailed -l 'Los Angeles, US' -u imperial 52 | Los Angeles, US 53 | Current: 67.1°F Clear sky 54 | Feels Like: 68.9°F 55 | Humidity: 80% 56 | Pressure: 1012.5hPa 57 | Wind: 4.6mp/h with gusts up to 5.8mp/h (W) 58 | UV Index: 8.5 59 | Precip: 0.0 inch (0% chance) 60 | Sunrise: 05:43am 61 | Sunset: 08:08pm 62 | 63 | Fri 06/27 61-85°F - Fog 64 | Sat 06/28 58-87°F - Fog 65 | Sun 06/29 58-85°F - Fog 66 | Mon 06/30 65-77°F - Clear sky 67 | Tue 07/01 64-79°F - Clear sky 68 | Wed 07/02 64-77°F - Clear sky 69 | Thu 07/03 63-74°F - Clear sky 70 | 71 | ### JSON 72 | 73 | $ outside -o json | jq 74 | { 75 | "city": "Edmonton", 76 | "country": "CA", 77 | "temperature": 17.6, 78 | "temperature_low": 9.1, 79 | "temperature_high": 21.7, 80 | "feels_like": 17.5, 81 | "temperature_unit": "°C", 82 | "wind_speed": 6.6, 83 | "wind_gusts": 13.0, 84 | "wind_speed_unit": "km/h", 85 | "wind_direction": 257, 86 | "wind_compass": "W", 87 | "weather_code": 3, 88 | "weather_icon": "󰖐", 89 | "weather_description": "Overcast", 90 | "openweather_code": "04d", 91 | "humidity": 72, 92 | "humidity_unit": "%", 93 | "pressure": 1006.9, 94 | "pressure_unit": "hPa", 95 | "sunrise": "05:07am", 96 | "sunset": "10:06pm", 97 | "uv_index": 6.2, 98 | "precipitation_chance": 53, 99 | "precipitation_sum": 0.8, 100 | "precipitation_unit": "mm", 101 | "precipitation_hours": 4.0, 102 | "forecast": [ 103 | { 104 | "date": "Fri 06/27", 105 | "weather_code": 80, 106 | "weather_icon": "󰖗", 107 | "weather_description": "Rain showers, slight", 108 | "openweather_code": "09d", 109 | "uv_index": 6.2, 110 | "precipitation_sum": 0.8, 111 | "precipitation_hours": 4.0, 112 | "precipitation_chance": 53, 113 | "temperature_high": 21.7, 114 | "temperature_low": 9.1 115 | }, 116 | ... 117 | { 118 | "date": "Thu 07/03", 119 | "weather_code": 80, 120 | "weather_icon": "󰖗", 121 | "weather_description": "Rain showers, slight", 122 | "openweather_code": "09d", 123 | "uv_index": 4.5, 124 | "precipitation_sum": 4.8, 125 | "precipitation_hours": 3.0, 126 | "precipitation_chance": 35, 127 | "temperature_high": 23.7, 128 | "temperature_low": 16.1 129 | } 130 | ], 131 | "cache_age": 355 132 | } 133 | 134 | ### Waybar 135 | 136 | $ outside -o waybar | jq 137 | { 138 | "text": "󰖐 18°C 󰖗 53%", 139 | "tooltip": "Edmonton, CA\nOvercast\nFeels Like 17.5 °C\nForecast 9-22 °C\nHumidity 72%\nPressure 1006.9 hPa\nWind 6.613.0 km/h (W)\nPrecip 0.8 mm (53% chance)\n\n 05:07am  10:06pm", 140 | "class": [], 141 | "percentage": 100 142 | } 143 | 144 | # Installation 145 | 146 | ### From crates.io 147 | 148 | ```bash 149 | cargo install outside 150 | ``` 151 | 152 | ### From Source 153 | 154 | ```bash 155 | cargo build --release 156 | cargo install --path . 157 | ``` 158 | 159 | ### Debian Package 160 | 161 | You will need the `ca-certificates` and `openssl` packages if they're not already installed. 162 | 163 | ```bash 164 | apt update 165 | dpkg -i outside_0.4.1_amd64.deb 166 | apt-get -f install 167 | ``` 168 | 169 | ### Alpine Linux 170 | 171 | ```bash 172 | apk add --allow-untrusted outside_0.4.1_x86_64.apk 173 | ``` 174 | 175 | ### Redhat / RPM Based Distributions 176 | 177 | ```bash 178 | rpm -i outside-0.4.1-1.x86_64.rpm 179 | ``` 180 | 181 | ### Other Linux Systems 182 | 183 | ```bash 184 | tar zxf outside-0.4.1_Linux_x86_64.tar.gz -C /usr/local/bin outside 185 | ``` 186 | 187 | # Configuration Options 188 | 189 | As an alternative to passing the command line options, the application will look for the following configuration file: 190 | 191 | ``` 192 | ~/.config/outside/config.yaml 193 | ``` 194 | 195 | An example configuration file: 196 | 197 | ```yaml 198 | units: Metric 199 | simple: 200 | template: "{weather_icon} {temperature | round}{temperature_unit} {wind_speed | round}{wind_gusts | round}" 201 | waybar: 202 | text: "{weather_icon} {temperature | round}{temperature_unit} {wind_speed | round}{wind_gusts | round}" 203 | hot_temperature: 30 204 | cold_temperature: 0 205 | ``` 206 | 207 | ### Available Template Variables 208 | 209 | You can run `outside -o json` to see a list of all the current variables and their values. 210 | 211 | # Waybar Configuration 212 | 213 | ![outside as a waybar module](https://github.com/BaconIsAVeg/outside/blob/main/screenshot.png?raw=true) 214 | 215 | Add the following configuration to your Waybar config file (usually located at `~/.config/waybar/config.jsonc`): 216 | 217 | ```jsonc 218 | "custom/weather": { 219 | "exec": "/path/to/outside -o waybar -s", 220 | "format": "{text}", 221 | "tooltip": true, 222 | "return-type": "json", 223 | } 224 | ``` 225 | 226 | And the corresponding CSS to style the widget (usually located at `~/.config/waybar/style.css`). Feel free to adjust the CSS to your liking: 227 | 228 | ```css 229 | #custom-weather { 230 | padding: 0.3rem 0.6rem; 231 | margin: 0.4rem 0.25rem; 232 | border-radius: 6px; 233 | background-color: #1a1a1f; 234 | color: #f9e2af; 235 | } 236 | ``` 237 | 238 | **Important**: You will also need a nerd patched font to display the weather icons. You can find one at [Nerd Fonts](https://www.nerdfonts.com/). Many distributions already include these fonts, so you may not need to install anything extra. 239 | 240 | ## Conditional Styling 241 | 242 | You can also add conditional styling based on the weather condition. For example, to change the background color based on the weather condition and have the module blink during adverse conditions, you can use the following CSS: 243 | 244 | ```css 245 | #custom-weather { 246 | animation-timing-function: linear; 247 | animation-iteration-count: infinite; 248 | animation-direction: alternate; 249 | } 250 | 251 | @keyframes blink-condition { 252 | to { 253 | background-color: #dedede; 254 | } 255 | } 256 | 257 | #custom-weather.hot { 258 | background-color: #dd5050; 259 | } 260 | 261 | #custom-weather.cold { 262 | background-color: #5050dd; 263 | } 264 | 265 | #custom-weather.rain, 266 | #custom-weather.snow, 267 | #custom-weather.fog { 268 | color: #dedede; 269 | animation-name: blink-condition; 270 | animation-duration: 2s; 271 | } 272 | 273 | ``` 274 | 275 | # License 276 | 277 | This project is licensed under the AGPL V3 or Greater - see the [LICENSE](LICENSE) file for details. 278 | -------------------------------------------------------------------------------- /src/api/weather.rs: -------------------------------------------------------------------------------- 1 | use crate::api::client; 2 | use crate::utils; 3 | use crate::Settings; 4 | 5 | use anyhow::{Context, Result}; 6 | use savefile::prelude::*; 7 | use savefile_derive::Savefile; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | #[derive(Default, Serialize, Deserialize, Debug, Savefile)] 11 | pub struct Weather { 12 | pub current: Current, 13 | pub current_units: CurrentUnits, 14 | pub elevation: f64, 15 | pub timezone: String, 16 | pub utc_offset_seconds: i32, 17 | pub daily: Daily, 18 | pub daily_units: DailyUnits, 19 | pub hourly: Hourly, 20 | pub hourly_units: HourlyUnits, 21 | pub latitude: f64, 22 | pub longitude: f64, 23 | #[serde(default)] 24 | pub created_at: u64, 25 | } 26 | 27 | #[derive(Default, Serialize, Deserialize, Debug, Savefile)] 28 | pub struct Current { 29 | pub apparent_temperature: f64, 30 | pub interval: i32, 31 | pub precipitation: f64, 32 | pub pressure_msl: f64, 33 | pub relative_humidity_2m: i32, 34 | pub temperature_2m: f64, 35 | pub weather_code: i32, 36 | pub wind_direction_10m: i32, 37 | pub wind_speed_10m: f64, 38 | pub wind_gusts_10m: f64, 39 | } 40 | 41 | #[derive(Default, Serialize, Deserialize, Debug, Savefile)] 42 | pub struct CurrentUnits { 43 | pub apparent_temperature: String, 44 | pub interval: String, 45 | pub precipitation: String, 46 | pub pressure_msl: String, 47 | pub relative_humidity_2m: String, 48 | pub temperature_2m: String, 49 | pub weather_code: String, 50 | pub wind_direction_10m: String, 51 | pub wind_speed_10m: String, 52 | pub wind_gusts_10m: String, 53 | } 54 | 55 | #[derive(Default, Serialize, Deserialize, Debug, Savefile)] 56 | pub struct Daily { 57 | pub time: Vec, 58 | pub weather_code: Vec, 59 | pub sunrise: Vec, 60 | pub sunset: Vec, 61 | pub uv_index_max: Vec, 62 | pub precipitation_sum: Vec, 63 | pub precipitation_hours: Vec, 64 | pub precipitation_probability_max: Vec, 65 | pub temperature_2m_max: Vec, 66 | pub temperature_2m_min: Vec, 67 | } 68 | 69 | #[derive(Default, Serialize, Deserialize, Debug, Savefile)] 70 | pub struct DailyUnits { 71 | pub time: String, 72 | pub weather_code: String, 73 | pub sunrise: String, 74 | pub sunset: String, 75 | pub uv_index_max: String, 76 | pub precipitation_sum: String, 77 | pub precipitation_hours: String, 78 | pub precipitation_probability_max: String, 79 | pub temperature_2m_max: String, 80 | pub temperature_2m_min: String, 81 | } 82 | 83 | #[derive(Default, Serialize, Deserialize, Debug, Savefile)] 84 | pub struct Hourly { 85 | pub time: Vec, 86 | pub temperature_2m: Vec, 87 | pub precipitation_probability: Vec, 88 | pub precipitation: Vec, 89 | pub weather_code: Vec, 90 | } 91 | 92 | #[derive(Default, Serialize, Deserialize, Debug, Savefile)] 93 | pub struct HourlyUnits { 94 | pub time: String, 95 | pub temperature_2m: String, 96 | pub precipitation_probability: String, 97 | pub precipitation: String, 98 | pub weather_code: String, 99 | } 100 | 101 | impl Weather { 102 | /// Retrieves weather data for the specified coordinates, using cached data if available. 103 | /// 104 | /// Weather data is cached for 10 minutes (580 seconds) to reduce API calls. 105 | /// If cached data is found for the same coordinates and is still fresh, it will be returned. 106 | /// Otherwise, fresh data will be fetched from the Open-Meteo API. 107 | /// 108 | /// # Arguments 109 | /// 110 | /// * `lat` - Latitude coordinate for the weather location 111 | /// * `lon` - Longitude coordinate for the weather location 112 | /// * `s` - Settings containing units and location information for caching 113 | /// 114 | /// # Returns 115 | /// 116 | /// Returns weather data on success, or an error if fetching fails. 117 | /// 118 | /// # Errors 119 | /// 120 | /// This function will return an error if: 121 | /// - The API request fails 122 | /// - The response cannot be parsed as JSON 123 | /// - Network connectivity issues occur 124 | pub fn get_cached(lat: f64, lon: f64, s: Settings) -> Result { 125 | let filename = utils::cache::get_cached_file("weather", &s.location); 126 | let now = utils::get_now(); 127 | 128 | let metric_unit_strings = utils::unitstrings::UnitStrings::metric(); 129 | 130 | let wd: Weather = load_file(&filename, 0).unwrap_or_default(); 131 | 132 | if wd.latitude == lat && wd.longitude == lon && wd.created_at > 0 && now - wd.created_at < 600 { 133 | return Ok(wd); 134 | } 135 | 136 | let mut data = 137 | Self::fetch(lat, lon, metric_unit_strings).with_context(|| "Failed to fetch weather data")?; 138 | data.latitude = format!("{:.1}", data.latitude).parse().unwrap_or(0.0); 139 | data.longitude = format!("{:.1}", data.longitude).parse().unwrap_or(0.0); 140 | data.created_at = now; 141 | 142 | match save_file(&filename, 0, &data) { 143 | Ok(_) => {}, 144 | Err(e) => eprintln!("Unable to save weather data to disk: {e:#?}"), 145 | } 146 | 147 | Ok(data) 148 | } 149 | 150 | /// Fetches fresh weather data from the Open-Meteo API. 151 | /// 152 | /// Constructs the API URL with the appropriate parameters for current weather, 153 | /// 7-day forecast, and unit preferences, then makes the HTTP request. 154 | /// 155 | /// # Arguments 156 | /// 157 | /// * `lat` - Latitude coordinate for the weather location 158 | /// * `lon` - Longitude coordinate for the weather location 159 | /// * `units` - Unit system for temperature, wind speed, and precipitation 160 | /// 161 | /// # Returns 162 | /// 163 | /// Returns parsed weather data on success, or an error if the request fails. 164 | /// 165 | /// # Errors 166 | /// 167 | /// This function will return an error if: 168 | /// - The HTTP request fails 169 | /// - The JSON response cannot be parsed 170 | /// - The API returns an error response 171 | fn fetch(lat: f64, lon: f64, units: utils::unitstrings::UnitStrings) -> Result { 172 | let base_url = "https://api.open-meteo.com/v1/forecast"; 173 | 174 | // https://api.open-meteo.com/v1/forecast\?latitude\=51.30011\&longitude\=-114.03528\&daily\=weather_code,temperature_2m_max,temperature_2m_min,sunset,sunrise,precipitation_hours,precipitation_probability_max\&hourly\=temperature_2m,precipitation_probability,precipitation\¤t\=temperature_2m,apparent_temperature,wind_speed_10m,wind_direction_10m,wind_gusts_10m,precipitation,weather_code,pressure_msl,relative_humidity_2m\&timezone\=America%2FDenver 175 | let hourly_fields = 176 | ["temperature_2m", "precipitation_probability", "precipitation", "weather_code"].join(","); 177 | 178 | let current_fields = [ 179 | "temperature_2m", 180 | "relative_humidity_2m", 181 | "apparent_temperature", 182 | "wind_speed_10m", 183 | "wind_direction_10m", 184 | "wind_gusts_10m", 185 | "precipitation", 186 | "weather_code", 187 | "pressure_msl", 188 | ] 189 | .join(","); 190 | 191 | let daily_fields = [ 192 | "sunrise", 193 | "sunset", 194 | "weather_code", 195 | "temperature_2m_max", 196 | "temperature_2m_min", 197 | "precipitation_sum", 198 | "precipitation_hours", 199 | "precipitation_probability_max", 200 | "uv_index_max", 201 | ] 202 | .join(","); 203 | 204 | let lat_str = lat.to_string(); 205 | let lon_str = lon.to_string(); 206 | 207 | let params: Vec<(&str, &str)> = vec![ 208 | ("latitude", lat_str.as_str()), 209 | ("longitude", lon_str.as_str()), 210 | ("timezone", "auto"), 211 | ("forecast_days", "7"), 212 | ("current", current_fields.as_str()), 213 | ("daily", daily_fields.as_str()), 214 | ("hourly", hourly_fields.as_str()), 215 | ("temperature_unit", units.temperature.as_str()), 216 | ("wind_speed_unit", units.wind_speed.as_str()), 217 | ("precipitation_unit", units.precipitation.as_str()), 218 | ]; 219 | 220 | let api_url = utils::urls::builder(base_url, params); 221 | 222 | let body = client::get_with_retry(&api_url, 2) 223 | .with_context(|| "Unable to fetch weather data from the Open-Meteo API endpoint")?; 224 | 225 | serde_json::from_str(&body).with_context(|| "Unable to parse weather response JSON") 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/tui/async_operations.rs: -------------------------------------------------------------------------------- 1 | use crate::api::location::LocationData; 2 | use crate::api::weather::Weather; 3 | use crate::context::Context; 4 | use crate::tui::constants::*; 5 | use crate::tui::state_manager::TuiStateManager; 6 | use crate::tui::ui_components::UiComponents; 7 | use crate::tui::weather_display::WeatherDisplay; 8 | use cursive::views::{ProgressBar, TextView}; 9 | use cursive::Cursive; 10 | use std::thread; 11 | 12 | pub struct WeatherFetcher { 13 | state_manager: TuiStateManager, 14 | } 15 | 16 | impl WeatherFetcher { 17 | pub fn new(state_manager: TuiStateManager) -> Self { 18 | Self { state_manager } 19 | } 20 | 21 | pub fn fetch_and_update( 22 | &self, 23 | location: String, 24 | siv: &mut Cursive, 25 | success_callback: F, 26 | error_callback: E, 27 | ) where 28 | F: Fn(&mut Cursive, &TuiStateManager, Context) + Send + 'static, 29 | E: Fn(&mut Cursive, &TuiStateManager, String) + Send + 'static, 30 | { 31 | // Set loading state 32 | self.state_manager.set_loading(true); 33 | 34 | // Update display to show loading 35 | self.update_ui_loading(siv); 36 | 37 | // Get a handle to the cursive instance for async update 38 | let cb_sink = siv.cb_sink().clone(); 39 | let location_clone = location.clone(); 40 | let state_manager_clone = self.state_manager.clone(); 41 | 42 | // Spawn background thread to fetch weather data 43 | thread::spawn(move || { 44 | if let Ok(result) = Self::fetch_weather_for_location(&location_clone, &state_manager_clone) { 45 | // Send callback to update UI on main thread 46 | cb_sink 47 | .send(Box::new(move |s| { 48 | // Note: we don't update the currently_selected_location here since success_callback will handle it 49 | state_manager_clone.update_context(result.clone()); 50 | success_callback(s, &state_manager_clone, result); 51 | })) 52 | .unwrap(); 53 | } else { 54 | // Handle error case 55 | let error_message = format!("Failed to fetch location data for: {location_clone}"); 56 | cb_sink 57 | .send(Box::new(move |s| { 58 | state_manager_clone.set_loading(false); 59 | error_callback(s, &state_manager_clone, error_message); 60 | })) 61 | .unwrap(); 62 | } 63 | }); 64 | } 65 | 66 | pub fn switch_location(&self, siv: &mut Cursive, location: String) { 67 | let location_clone = location.clone(); 68 | self.fetch_and_update( 69 | location, 70 | siv, 71 | move |s, state_manager, context| { 72 | // Update both context and currently selected location 73 | state_manager.update_context_with_location(context, location_clone.clone()); 74 | UiComponents::update_weather_display_components(s, state_manager); 75 | }, 76 | |s, _state_manager, error_message| { 77 | Self::show_error_dialog(s, &error_message); 78 | }, 79 | ); 80 | } 81 | 82 | pub fn toggle_units(&self, siv: &mut Cursive) { 83 | let current_location = self.state_manager.get_current_location(); 84 | self.state_manager.toggle_units(); 85 | self.state_manager.set_loading(true); 86 | 87 | // Update display to show units switching 88 | siv.call_on_name(WEATHER_HEADER_NAME, |view: &mut TextView| { 89 | view.set_content(WeatherDisplay::format_units_switching_message()); 90 | }); 91 | siv.call_on_name(WEATHER_CURRENT_NAME, |view: &mut TextView| { 92 | view.set_content(WeatherDisplay::format_wait_message()); 93 | }); 94 | siv.call_on_name(WEATHER_FORECAST_NAME, |view: &mut TextView| { 95 | view.set_content(""); 96 | }); 97 | 98 | let cb_sink = siv.cb_sink().clone(); 99 | let state_manager_clone = self.state_manager.clone(); 100 | 101 | thread::spawn(move || { 102 | if let Ok(result) = Self::fetch_weather_for_location(¤t_location, &state_manager_clone) { 103 | let location_for_update = current_location.clone(); 104 | cb_sink 105 | .send(Box::new(move |s| { 106 | state_manager_clone.update_context_with_location(result, location_for_update); 107 | UiComponents::update_weather_display_components(s, &state_manager_clone); 108 | })) 109 | .unwrap(); 110 | } else { 111 | cb_sink 112 | .send(Box::new(move |s| { 113 | state_manager_clone.set_loading(false); 114 | Self::show_error_dialog(s, "Failed to fetch weather data with new units"); 115 | // Revert to previous weather display 116 | UiComponents::update_weather_display_components(s, &state_manager_clone); 117 | })) 118 | .unwrap(); 119 | } 120 | }); 121 | } 122 | 123 | pub fn setup_auto_refresh(&self, siv: &mut Cursive) { 124 | let cb_sink = siv.cb_sink().clone(); 125 | let state_manager_clone = self.state_manager.clone(); 126 | 127 | thread::spawn(move || loop { 128 | thread::sleep(std::time::Duration::from_secs(AUTO_REFRESH_INTERVAL)); 129 | 130 | if state_manager_clone.needs_refresh() { 131 | // Fetch new data when cache expires 132 | let current_location = state_manager_clone.get_current_location(); 133 | let state_for_refresh = state_manager_clone.clone(); 134 | 135 | let _ = cb_sink.send(Box::new(move |s| { 136 | let fetcher = WeatherFetcher::new(state_for_refresh); 137 | fetcher.switch_location(s, current_location); 138 | })); 139 | } else { 140 | // Update display to show current cache age without fetching new data 141 | let state_for_display = state_manager_clone.clone(); 142 | let _ = cb_sink.send(Box::new(move |s| { 143 | // Update cache_age in context to current time difference 144 | state_for_display.update_cache_age(); 145 | UiComponents::update_weather_display_components(s, &state_for_display); 146 | })); 147 | } 148 | }); 149 | } 150 | 151 | fn update_ui_loading(&self, siv: &mut Cursive) { 152 | siv.call_on_name(WEATHER_HEADER_NAME, |view: &mut TextView| { 153 | view.set_content(WeatherDisplay::format_loading_message()); 154 | }); 155 | siv.call_on_name(WEATHER_CURRENT_NAME, |view: &mut TextView| { 156 | view.set_content(WeatherDisplay::format_wait_message()); 157 | }); 158 | siv.call_on_name(WEATHER_FORECAST_NAME, |view: &mut TextView| { 159 | view.set_content(""); 160 | }); 161 | siv.call_on_name(DATA_AGE_PROGRESS_NAME, |view: &mut ProgressBar| { 162 | view.set_value(0); 163 | }); 164 | } 165 | 166 | fn show_error_dialog(siv: &mut Cursive, message: &str) { 167 | siv.add_layer(cursive::views::Dialog::text(message).title("Error").button("OK", |s| { 168 | s.pop_layer(); 169 | })); 170 | } 171 | 172 | fn fetch_weather_for_location( 173 | location: &str, 174 | state_manager: &TuiStateManager, 175 | ) -> Result> { 176 | let mut settings = state_manager.get_settings(); 177 | 178 | // Handle special "Automatic" case for IP-based lookup 179 | if location == "Automatic" { 180 | settings.location = String::new(); // Empty string triggers IP lookup 181 | } else { 182 | // Parse location for geocoding API 183 | let parts: Vec<&str> = location.split(',').collect(); 184 | if parts.len() != 2 { 185 | return Err("Invalid location format".into()); 186 | } 187 | settings.location = location.to_string(); 188 | } 189 | 190 | // Fetch location data 191 | let location_data = LocationData::get_cached(settings.clone())?; 192 | 193 | // Fetch weather data 194 | let weather_data = 195 | Weather::get_cached(location_data.latitude, location_data.longitude, settings.clone())?; 196 | 197 | // Build context 198 | let context = Context::build(weather_data, location_data, settings); 199 | 200 | Ok(context) 201 | } 202 | } 203 | 204 | impl Clone for WeatherFetcher { 205 | fn clone(&self) -> Self { 206 | Self { state_manager: self.state_manager.clone() } 207 | } 208 | } 209 | 210 | impl Clone for TuiStateManager { 211 | fn clone(&self) -> Self { 212 | Self { state: self.state.clone() } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/tui/keyboard_handlers.rs: -------------------------------------------------------------------------------- 1 | use crate::api::location::LocationData; 2 | use crate::tui::async_operations::WeatherFetcher; 3 | use crate::tui::constants::*; 4 | use crate::tui::location_manager::LocationManager; 5 | use crate::tui::state_manager::TuiStateManager; 6 | use crate::tui::ui_components::UiComponents; 7 | use cursive::views::{Dialog, EditView, SelectView}; 8 | use cursive::Cursive; 9 | 10 | pub struct KeyboardHandlers; 11 | 12 | impl KeyboardHandlers { 13 | pub fn setup_all_handlers( 14 | siv: &mut Cursive, 15 | state_manager: TuiStateManager, 16 | location_manager: LocationManager, 17 | weather_fetcher: WeatherFetcher, 18 | ) { 19 | Self::setup_quit_handlers(siv); 20 | Self::setup_location_handlers(siv, state_manager.clone(), location_manager, weather_fetcher.clone()); 21 | Self::setup_unit_toggle_handler(siv, weather_fetcher); 22 | Self::setup_forecast_toggle_handler(siv, state_manager); 23 | } 24 | 25 | fn setup_quit_handlers(siv: &mut Cursive) { 26 | siv.add_global_callback('q', |s| s.quit()); 27 | siv.add_global_callback(cursive::event::Key::Esc, |s| { 28 | // If there are dialog layers, close the top one; otherwise quit 29 | if s.screen().len() > 1 { 30 | s.pop_layer(); 31 | } else { 32 | s.quit(); 33 | } 34 | }); 35 | } 36 | 37 | fn setup_location_handlers( 38 | siv: &mut Cursive, 39 | state_manager: TuiStateManager, 40 | location_manager: LocationManager, 41 | weather_fetcher: WeatherFetcher, 42 | ) { 43 | // Add location 44 | Self::setup_add_location_handler( 45 | siv, 46 | state_manager.clone(), 47 | location_manager.clone(), 48 | weather_fetcher.clone(), 49 | ); 50 | 51 | // Delete location 52 | Self::setup_delete_location_handler(siv, location_manager.clone()); 53 | 54 | // Switch location (Enter key) 55 | Self::setup_switch_location_handler(siv, weather_fetcher); 56 | } 57 | 58 | fn setup_add_location_handler( 59 | siv: &mut Cursive, 60 | state_manager: TuiStateManager, 61 | location_manager: LocationManager, 62 | weather_fetcher: WeatherFetcher, 63 | ) { 64 | siv.add_global_callback('a', move |s| { 65 | let state_manager_clone = state_manager.clone(); 66 | let location_manager_clone = location_manager.clone(); 67 | let weather_fetcher_clone = weather_fetcher.clone(); 68 | 69 | let add_location_fn = { 70 | let state_manager_for_add = state_manager_clone.clone(); 71 | let location_manager_for_add = location_manager_clone.clone(); 72 | let weather_fetcher_for_add = weather_fetcher_clone.clone(); 73 | 74 | move |s: &mut Cursive| { 75 | let location = s 76 | .call_on_name(NEW_LOCATION_NAME, |view: &mut EditView| view.get_content()) 77 | .unwrap() 78 | .to_string(); 79 | 80 | if !location.is_empty() { 81 | let normalized_location = if location != "Automatic" { 82 | LocationData::normalize_location_string(&location) 83 | } else { 84 | location.clone() 85 | }; 86 | 87 | // Check if normalized location already exists 88 | let location_list = location_manager_for_add.get_location_list(); 89 | let list = location_list.lock().unwrap(); 90 | if list.locations.contains(&normalized_location) { 91 | drop(list); 92 | s.add_layer( 93 | Dialog::text(format!( 94 | "Location '{normalized_location}' is already in the list." 95 | )) 96 | .title("Bookmark Already Exists") 97 | .button("OK", |s| { 98 | s.pop_layer(); 99 | }), 100 | ); 101 | return; 102 | } 103 | drop(list); 104 | 105 | // Add and switch to the new location 106 | Self::add_and_switch_location( 107 | s, 108 | &state_manager_for_add, 109 | location_manager_for_add.clone(), 110 | &weather_fetcher_for_add, 111 | normalized_location, 112 | ); 113 | } 114 | s.pop_layer(); 115 | } 116 | }; 117 | 118 | let add_fn_clone = add_location_fn.clone(); 119 | 120 | // Create dialog with submit handler for Enter key 121 | let dialog = UiComponents::create_add_location_dialog(move |s, _content| { 122 | add_fn_clone(s); 123 | }) 124 | .button("Add", add_location_fn) 125 | .button("Cancel", |s| { 126 | s.pop_layer(); 127 | }); 128 | 129 | s.add_layer(dialog); 130 | }); 131 | } 132 | 133 | fn setup_delete_location_handler(siv: &mut Cursive, location_manager: LocationManager) { 134 | siv.add_global_callback('d', move |s| { 135 | let (selected_index, selected_location) = s 136 | .call_on_name(LOCATION_LIST_NAME, |view: &mut SelectView| { 137 | let index = view.selected_id(); 138 | let location = view.selection().map(|sel| sel.as_ref().clone()); 139 | (index, location) 140 | }) 141 | .unwrap_or((None, None)); 142 | 143 | if let (Some(index), Some(location)) = (selected_index, selected_location) { 144 | let location_manager_clone = location_manager.clone(); 145 | let dialog = UiComponents::create_delete_confirmation_dialog(&location) 146 | .button("Delete", move |s| { 147 | location_manager_clone.remove_location_by_name(&location); 148 | 149 | // Update the select view 150 | s.call_on_name(LOCATION_LIST_NAME, |view: &mut SelectView| { 151 | view.remove_item(index); 152 | }); 153 | s.pop_layer(); 154 | }) 155 | .button("Cancel", |s| { 156 | s.pop_layer(); 157 | }); 158 | 159 | s.add_layer(dialog); 160 | } 161 | }); 162 | } 163 | 164 | fn setup_switch_location_handler(siv: &mut Cursive, weather_fetcher: WeatherFetcher) { 165 | siv.add_global_callback(cursive::event::Key::Enter, move |s| { 166 | let selected = s.call_on_name(LOCATION_LIST_NAME, |view: &mut SelectView| { 167 | view.selection().map(|sel| sel.as_ref().clone()) 168 | }); 169 | 170 | if let Some(Some(location)) = selected { 171 | weather_fetcher.switch_location(s, location); 172 | } 173 | }); 174 | } 175 | 176 | fn setup_unit_toggle_handler(siv: &mut Cursive, weather_fetcher: WeatherFetcher) { 177 | siv.add_global_callback('u', move |s| { 178 | weather_fetcher.toggle_units(s); 179 | }); 180 | } 181 | 182 | fn setup_forecast_toggle_handler(siv: &mut Cursive, state_manager: TuiStateManager) { 183 | siv.add_global_callback('f', move |s| { 184 | state_manager.toggle_forecast_mode(); 185 | UiComponents::update_weather_display_components(s, &state_manager); 186 | }); 187 | } 188 | 189 | fn add_and_switch_location( 190 | siv: &mut Cursive, 191 | _state_manager: &TuiStateManager, 192 | location_manager: LocationManager, 193 | weather_fetcher: &WeatherFetcher, 194 | location: String, 195 | ) { 196 | let location_clone = location.clone(); 197 | let location_manager_clone = location_manager.clone(); 198 | 199 | weather_fetcher.fetch_and_update( 200 | location, 201 | siv, 202 | move |s, state_manager, result| { 203 | // Update both context and currently selected location 204 | state_manager.update_context_with_location(result, location_clone.clone()); 205 | 206 | // Add to location list 207 | location_manager_clone.add_location(location_clone.clone()); 208 | 209 | // Update the select view and select the new location 210 | s.call_on_name(LOCATION_LIST_NAME, |view: &mut SelectView| { 211 | let target_index = location_manager_clone.rebuild_select_view(view, &location_clone); 212 | if let Some(index) = target_index { 213 | view.set_selection(index); 214 | } 215 | }); 216 | 217 | // Update the weather display 218 | UiComponents::update_weather_display_components(s, state_manager); 219 | }, 220 | |s, state_manager, error_message| { 221 | // Show error dialog and revert to previous weather display 222 | Self::show_error_dialog(s, &error_message); 223 | UiComponents::update_weather_display_components(s, state_manager); 224 | }, 225 | ); 226 | } 227 | 228 | fn show_error_dialog(siv: &mut Cursive, message: &str) { 229 | siv.add_layer(Dialog::text(message).title("Error").button("OK", |s| { 230 | s.pop_layer(); 231 | })); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/context.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::conversions; 2 | use crate::utils::mappings; 3 | use crate::utils::*; 4 | use crate::weather::Weather; 5 | use crate::{LocationData, Settings, Units}; 6 | 7 | use serde::{Deserialize, Serialize}; 8 | 9 | #[derive(Deserialize, Serialize, Debug, Clone)] 10 | pub struct Context { 11 | pub city: String, 12 | pub country: String, 13 | pub temperature: f64, 14 | pub temperature_low: f64, 15 | pub temperature_high: f64, 16 | pub feels_like: f64, 17 | pub temperature_unit: String, 18 | pub wind_speed: f64, 19 | pub wind_gusts: f64, 20 | pub wind_speed_unit: String, 21 | pub wind_direction: i32, 22 | pub wind_compass: String, 23 | pub weather_code: i32, 24 | pub weather_icon: String, 25 | pub weather_description: String, 26 | pub openweather_code: String, 27 | pub humidity: i32, 28 | pub humidity_unit: String, 29 | pub pressure: f64, 30 | pub pressure_unit: String, 31 | pub sunrise: String, 32 | pub sunset: String, 33 | pub uv_index: f64, 34 | pub precipitation_chance: i32, 35 | pub precipitation_sum: f64, 36 | pub precipitation_unit: String, 37 | pub precipitation_hours: f64, 38 | pub precipitation_start: Option, 39 | pub precipitation_end: Option, 40 | pub precipitation_description: Option, 41 | pub forecast: Vec, 42 | pub hourly: Vec, 43 | pub cache_age: u64, 44 | } 45 | 46 | #[derive(Deserialize, Serialize, Debug, Clone)] 47 | pub struct ContextDaily { 48 | pub date: String, 49 | pub weather_code: i32, 50 | pub weather_icon: String, 51 | pub weather_description: String, 52 | pub openweather_code: String, 53 | pub uv_index: f64, 54 | pub precipitation_sum: f64, 55 | pub precipitation_hours: f64, 56 | pub precipitation_chance: i32, 57 | pub temperature_high: f64, 58 | pub temperature_low: f64, 59 | } 60 | 61 | #[derive(Deserialize, Serialize, Debug, Clone)] 62 | pub struct ContextHourly { 63 | pub time: String, 64 | pub temperature: f64, 65 | pub precipitation_probability: i32, 66 | pub precipitation: f64, 67 | pub weather_code: i32, 68 | pub weather_icon: String, 69 | } 70 | 71 | impl Context { 72 | /// Builds a unified context structure from weather data and location information. 73 | /// 74 | /// This function transforms raw API data into a structured format suitable for 75 | /// template rendering. It combines current weather conditions, daily forecasts, 76 | /// and location data into a single context object with processed values. 77 | /// 78 | /// The function performs several data transformations: 79 | /// - Converts weather codes to human-readable descriptions and icons 80 | /// - Transforms wind direction degrees to compass directions 81 | /// - Converts ISO8601 timestamps to human-readable time/date strings 82 | /// - Calculates cache age for freshness indication 83 | /// - Builds a 7-day forecast array with processed daily data 84 | /// 85 | /// # Arguments 86 | /// 87 | /// * `weather` - Weather data structure containing current conditions and forecasts (always in metric) 88 | /// * `location` - Location data containing city, country, and coordinates 89 | /// * `settings` - Settings containing units and other configuration 90 | /// 91 | /// # Returns 92 | /// 93 | /// Returns a `Context` struct containing all processed weather and location data 94 | /// ready for template rendering across different output formats. 95 | pub fn build(weather: Weather, location: LocationData, settings: Settings) -> Self { 96 | let now = get_now(); 97 | 98 | let current = &weather.current; 99 | let daily = &weather.daily; 100 | let hourly = &weather.hourly; 101 | let openweather_code = mappings::meteo2openweather_codes(current.weather_code); 102 | let wind_compass = mappings::degrees2compass(current.wind_direction_10m as f64); 103 | 104 | let weather_description = mappings::weather_description(current.weather_code); 105 | let weather_icon = mappings::weather_code2icon(current.weather_code); 106 | 107 | let sunrise = conversions::iso8601_to_time(daily.sunrise[0].clone(), settings.hour24); 108 | let sunset = conversions::iso8601_to_time(daily.sunset[0].clone(), settings.hour24); 109 | 110 | let cache_age = now - weather.created_at; 111 | 112 | // Convert values based on user settings 113 | let is_imperial = settings.units == Units::Imperial; 114 | 115 | // Convert current weather values 116 | let temperature = if is_imperial { 117 | conversions::celsius_to_fahrenheit(current.temperature_2m) 118 | } else { 119 | current.temperature_2m 120 | }; 121 | let feels_like = if is_imperial { 122 | conversions::celsius_to_fahrenheit(current.apparent_temperature) 123 | } else { 124 | current.apparent_temperature 125 | }; 126 | let wind_speed = if is_imperial { 127 | conversions::kmh_to_mph(current.wind_speed_10m) 128 | } else { 129 | current.wind_speed_10m 130 | }; 131 | let wind_gusts = if is_imperial { 132 | conversions::kmh_to_mph(current.wind_gusts_10m) 133 | } else { 134 | current.wind_gusts_10m 135 | }; 136 | 137 | // Convert daily values for today 138 | let temperature_low = if is_imperial { 139 | conversions::celsius_to_fahrenheit(daily.temperature_2m_min[0]) 140 | } else { 141 | daily.temperature_2m_min[0] 142 | }; 143 | let temperature_high = if is_imperial { 144 | conversions::celsius_to_fahrenheit(daily.temperature_2m_max[0]) 145 | } else { 146 | daily.temperature_2m_max[0] 147 | }; 148 | let precipitation_sum = if is_imperial { 149 | conversions::mm_to_inches(daily.precipitation_sum[0]) 150 | } else { 151 | daily.precipitation_sum[0] 152 | }; 153 | 154 | let dailies: Vec = daily 155 | .time 156 | .iter() 157 | .enumerate() 158 | .map(|(i, date)| ContextDaily { 159 | date: conversions::iso8601_to_date(date.clone()), 160 | weather_code: daily.weather_code[i], 161 | weather_icon: mappings::weather_code2icon(daily.weather_code[i]), 162 | weather_description: mappings::weather_description(daily.weather_code[i]), 163 | openweather_code: mappings::meteo2openweather_codes(daily.weather_code[i]), 164 | uv_index: daily.uv_index_max[i], 165 | precipitation_sum: if is_imperial { 166 | conversions::mm_to_inches(daily.precipitation_sum[i]) 167 | } else { 168 | daily.precipitation_sum[i] 169 | }, 170 | precipitation_hours: daily.precipitation_hours[i], 171 | precipitation_chance: daily.precipitation_probability_max[i], 172 | temperature_high: if is_imperial { 173 | conversions::celsius_to_fahrenheit(daily.temperature_2m_max[i]) 174 | } else { 175 | daily.temperature_2m_max[i] 176 | }, 177 | temperature_low: if is_imperial { 178 | conversions::celsius_to_fahrenheit(daily.temperature_2m_min[i]) 179 | } else { 180 | daily.temperature_2m_min[i] 181 | }, 182 | }) 183 | .collect(); 184 | 185 | // Find the current hour index to start hourly forecast from current time 186 | let current_hour_index = Self::find_current_hour_index(&hourly.time, now, weather.utc_offset_seconds); 187 | 188 | let hourlies: Vec = hourly 189 | .time 190 | .iter() 191 | .enumerate() 192 | .skip(current_hour_index) 193 | .take(24) 194 | .map(|(i, time)| ContextHourly { 195 | time: conversions::iso8601_to_time(time.clone(), settings.hour24), 196 | temperature: if is_imperial { 197 | conversions::celsius_to_fahrenheit(hourly.temperature_2m[i]) 198 | } else { 199 | hourly.temperature_2m[i] 200 | }, 201 | precipitation_probability: hourly.precipitation_probability[i], 202 | precipitation: if is_imperial { 203 | conversions::mm_to_inches(hourly.precipitation[i]) 204 | } else { 205 | hourly.precipitation[i] 206 | }, 207 | weather_code: hourly.weather_code[i], 208 | weather_icon: mappings::weather_code2icon(hourly.weather_code[i]), 209 | }) 210 | .collect(); 211 | 212 | // Calculate precipitation start and end times (accounting for current hour offset) 213 | let (precipitation_start, precipitation_end) = 214 | Self::calculate_precipitation_timing(hourly, current_hour_index); 215 | 216 | // Create precipitation description 217 | let precipitation_description = Self::create_precipitation_description( 218 | precipitation_start, 219 | precipitation_end, 220 | hourly, 221 | current_hour_index, 222 | ); 223 | 224 | Context { 225 | city: location.city, 226 | country: location.country_code, 227 | temperature, 228 | temperature_low, 229 | temperature_high, 230 | feels_like, 231 | temperature_unit: if is_imperial { "°F".to_string() } else { "°C".to_string() }, 232 | wind_speed, 233 | wind_gusts, 234 | wind_speed_unit: if is_imperial { "mph".to_string() } else { "km/h".to_string() }, 235 | wind_direction: current.wind_direction_10m, 236 | wind_compass, 237 | weather_code: current.weather_code, 238 | weather_icon, 239 | weather_description, 240 | openweather_code, 241 | humidity: current.relative_humidity_2m, 242 | humidity_unit: "%".to_string(), 243 | pressure: current.pressure_msl, 244 | pressure_unit: "hPa".to_string(), 245 | sunrise, 246 | sunset, 247 | uv_index: daily.uv_index_max[0], 248 | precipitation_chance: daily.precipitation_probability_max[0], 249 | precipitation_sum, 250 | precipitation_unit: if is_imperial { "in".to_string() } else { "mm".to_string() }, 251 | precipitation_hours: daily.precipitation_hours[0], 252 | precipitation_start, 253 | precipitation_end, 254 | precipitation_description, 255 | forecast: dailies, 256 | hourly: hourlies, 257 | 258 | cache_age, 259 | } 260 | } 261 | 262 | /// Finds the index of the current hour in the hourly time array. 263 | /// 264 | /// This function converts the current UTC timestamp to the location's timezone, 265 | /// then finds the hourly entry that corresponds to the current local time. 266 | /// This is used to start the 24-hour forecast from the current time rather than from midnight. 267 | /// 268 | /// # Arguments 269 | /// 270 | /// * `hourly_times` - Array of ISO8601 datetime strings from the API (in location's timezone) 271 | /// * `current_timestamp` - Current Unix timestamp in seconds (UTC) 272 | /// * `utc_offset_seconds` - UTC offset for the location's timezone 273 | /// 274 | /// # Returns 275 | /// 276 | /// Returns the index of the hour closest to the current local time, or 0 if no match is found. 277 | fn find_current_hour_index( 278 | hourly_times: &[String], 279 | current_timestamp: u64, 280 | utc_offset_seconds: i32, 281 | ) -> usize { 282 | use chrono::{FixedOffset, NaiveDateTime, TimeZone}; 283 | 284 | // Create timezone offset from the location's UTC offset 285 | let timezone = FixedOffset::east_opt(utc_offset_seconds).unwrap_or(FixedOffset::east_opt(0).unwrap()); 286 | 287 | // Convert current UTC timestamp to the location's timezone 288 | let current_local = timezone.timestamp_opt(current_timestamp as i64, 0).unwrap(); 289 | 290 | for (i, time_str) in hourly_times.iter().enumerate() { 291 | if let Ok(hour_dt) = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%dT%H:%M") { 292 | let hour_local = timezone.from_local_datetime(&hour_dt).unwrap(); 293 | 294 | // If this hour is at or after the current local time, use this index 295 | if hour_local >= current_local { 296 | return i; 297 | } 298 | } 299 | } 300 | 301 | // If no hour found at or after current time, start from beginning 302 | 0 303 | } 304 | 305 | /// Calculates when precipitation is expected to start or stop based on hourly data. 306 | /// 307 | /// Returns the number of hours until precipitation starts (if currently none) 308 | /// or stops (if currently precipitating). 309 | /// 310 | /// # Arguments 311 | /// 312 | /// * `hourly` - Hourly weather data from API (always in metric) 313 | /// 314 | /// # Returns 315 | /// 316 | /// Returns a tuple of (precipitation_start, precipitation_end) where: 317 | /// - precipitation_start: Hours until precipitation starts (if not currently precipitating) 318 | /// - precipitation_end: Hours until precipitation ends (if currently precipitating) 319 | /// Both values are None if the condition doesn't occur within the 24-hour forecast. 320 | fn calculate_precipitation_timing( 321 | hourly: &crate::weather::Hourly, 322 | current_hour_index: usize, 323 | ) -> (Option, Option) { 324 | let mut precipitation_start = None; 325 | let mut precipitation_end = None; 326 | 327 | // Get current precipitation status (at current hour index) 328 | let current_precipitation = if hourly.precipitation.len() > current_hour_index { 329 | hourly.precipitation[current_hour_index] 330 | } else { 331 | 0.0 332 | }; 333 | 334 | let currently_precipitating = current_precipitation > 0.0; 335 | 336 | // Look through the next 24 hours starting from current hour 337 | for (i, &precip) in hourly.precipitation.iter().enumerate().skip(current_hour_index).take(24) { 338 | let is_precipitating = precip > 0.0; 339 | let hours_from_now = (i - current_hour_index) as i32; 340 | 341 | if !currently_precipitating && is_precipitating && precipitation_start.is_none() { 342 | // Found when precipitation starts 343 | precipitation_start = Some(hours_from_now); 344 | } else if currently_precipitating && !is_precipitating && precipitation_end.is_none() { 345 | // Found when precipitation ends 346 | precipitation_end = Some(hours_from_now); 347 | } 348 | 349 | // If we've found both conditions or the relevant one, we can break 350 | if (!currently_precipitating && precipitation_start.is_some()) 351 | || (currently_precipitating && precipitation_end.is_some()) 352 | { 353 | break; 354 | } 355 | } 356 | 357 | (precipitation_start, precipitation_end) 358 | } 359 | 360 | /// Creates a human-readable description of precipitation timing. 361 | /// 362 | /// # Arguments 363 | /// 364 | /// * `precipitation_start` - Hours until precipitation starts (if not currently precipitating) 365 | /// * `precipitation_end` - Hours until precipitation ends (if currently precipitating) 366 | /// * `hourly` - Hourly weather data to determine current precipitation status 367 | /// 368 | /// # Returns 369 | /// 370 | /// Returns an Option with a description like "Starts in 6 hours" or "Stops in 2 hours" 371 | /// Returns None if no precipitation timing is available. 372 | fn create_precipitation_description( 373 | precipitation_start: Option, 374 | precipitation_end: Option, 375 | hourly: &crate::weather::Hourly, 376 | current_hour_index: usize, 377 | ) -> Option { 378 | // Determine current precipitation status (at current hour index) 379 | let current_precipitation = if hourly.precipitation.len() > current_hour_index { 380 | hourly.precipitation[current_hour_index] 381 | } else { 382 | 0.0 383 | }; 384 | let currently_precipitating = current_precipitation > 0.0; 385 | 386 | if currently_precipitating { 387 | // Show when precipitation will end 388 | if let Some(hours) = precipitation_end { 389 | let hour_text = if hours == 1 { "hour" } else { "hours" }; 390 | Some(format!("Stops in {hours} {hour_text}")) 391 | } else { 392 | None 393 | } 394 | } else { 395 | // Show when precipitation will start 396 | if let Some(hours) = precipitation_start { 397 | let hour_text = if hours == 1 { "hour" } else { "hours" }; 398 | Some(format!("Starts in {hours} {hour_text}")) 399 | } else { 400 | None 401 | } 402 | } 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | --------------------------------------------------------------------------------