diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9703579 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.cargo/ +*.pdb +**/*.rs.bk +debug/ +target/ +vendor/ +vendor.tar +debian/* +!debian/changelog +!debian/control +!debian/copyright +!debian/install +!debian/rules +!debian/source \ No newline at end of file diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..2f12f59 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,3 @@ +[tools] +rust = "1.92.0" +rust-analyzer = "latest" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4060d82 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "cosmic-weather-applet" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Weather applet for COSMIC" +repository = "https://git.lerch.org/lobo/cosmic-weather-applet" + +[dependencies] +futures-util = "0.3.31" +i18n-embed = { version = "0.16", features = [ + "fluent-system", + "desktop-requester", +] } +i18n-embed-fl = "0.10" +open = "5.3.2" +rust-embed = "8.8.0" +tokio = { version = "1.48.0", features = ["full"] } + +[dependencies.libcosmic] +git = "https://github.com/pop-os/libcosmic.git" +# See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features. +features = [ + # Accessibility support + "a11y", + # About widget for the app + "about", + # Uses cosmic-settings-daemon to watch for config file changes + "dbus-config", + # Support creating additional application windows. + "multi-window", + # On app startup, focuses an existing instance if the app is already open + "single-instance", + # Uses tokio as the executor for the runtime + "tokio", + # Windowing support for X11, Windows, Mac, & Redox + "winit", + # Add Wayland support to winit + "wayland", + # GPU-accelerated rendering + "wgpu", +] + +# Uncomment to test a locally-cloned libcosmic +# [patch.'https://github.com/pop-os/libcosmic'] +# libcosmic = { path = "../libcosmic" } +# cosmic-config = { path = "../libcosmic/cosmic-config" } +# cosmic-theme = { path = "../libcosmic/cosmic-theme" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2252f67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Emil Lerch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fcbff2 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Cosmic Weather Applet + +Weather applet for COSMIC + +## Installation + +A [justfile](./justfile) is included by default for the [casey/just][just] command runner. + +- `just` builds the application with the default `just build-release` recipe +- `just run` builds and runs the application +- `just install` installs the project into the system +- `just vendor` creates a vendored tarball +- `just build-vendored` compiles with vendored dependencies from that tarball +- `just check` runs clippy on the project to check for linter warnings +- `just check-json` can be used by IDEs that support LSP + +## Translators + +[Fluent][fluent] is used for localization of the software. Fluent's translation files are found in the [i18n directory](./i18n). New translations may copy the [English (en) localization](./i18n/en) of the project, rename `en` to the desired [ISO 639-1 language code][iso-codes], and then translations can be provided for each [message identifier][fluent-guide]. If no translation is necessary, the message may be omitted. + +## Packaging + +If packaging for a Linux distribution, vendor dependencies locally with the `vendor` rule, and build with the vendored sources using the `build-vendored` rule. When installing files, use the `rootdir` and `prefix` variables to change installation paths. + +```sh +just vendor +just build-vendored +just rootdir=debian/cosmic-weather-applet prefix=/usr install +``` + +It is recommended to build a source tarball with the vendored dependencies, which can typically be done by running `just vendor` on the host system before it enters the build environment. + +## Developers + +Developers should install [rustup][rustup] and configure their editor to use [rust-analyzer][rust-analyzer]. To improve compilation times, disable LTO in the release profile, install the [mold][mold] linker, and configure [sccache][sccache] for use with Rust. The [mold][mold] linker will only improve link times if LTO is disabled. + +[fluent]: https://projectfluent.org/ +[fluent-guide]: https://projectfluent.org/fluent/guide/hello.html +[iso-codes]: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes +[just]: https://github.com/casey/just +[rustup]: https://rustup.rs/ +[rust-analyzer]: https://rust-analyzer.github.io/ +[mold]: https://github.com/rui314/mold +[sccache]: https://github.com/mozilla/sccache diff --git a/i18n.toml b/i18n.toml new file mode 100644 index 0000000..05c50ba --- /dev/null +++ b/i18n.toml @@ -0,0 +1,4 @@ +fallback_language = "en" + +[fluent] +assets_dir = "i18n" \ No newline at end of file diff --git a/i18n/en/cosmic_weather_applet.ftl b/i18n/en/cosmic_weather_applet.ftl new file mode 100644 index 0000000..43972e3 --- /dev/null +++ b/i18n/en/cosmic_weather_applet.ftl @@ -0,0 +1,7 @@ +app-title = Cosmic Weather Applet +about = About +repository = Repository +view = View +welcome = Welcome to COSMIC! ✨ +page-id = Page { $num } +git-description = Git commit {$hash} on {$date} diff --git a/justfile b/justfile new file mode 100644 index 0000000..7b988e4 --- /dev/null +++ b/justfile @@ -0,0 +1,96 @@ +# Name of the application's binary. +name := 'cosmic-weather-applet' +# The unique ID of the application. +appid := 'com.github.elerch.cosmic-weather-applet' + +# Path to root file system, which defaults to `/`. +rootdir := '' +# The prefix for the `/usr` directory. +prefix := '/usr' +# The location of the cargo target directory. +cargo-target-dir := env('CARGO_TARGET_DIR', 'target') + +# Application's appstream metadata +appdata := appid + '.metainfo.xml' +# Application's desktop entry +desktop := appid + '.desktop' +# Application's icon. +icon-svg := appid + '.svg' + +# Install destinations +base-dir := absolute_path(clean(rootdir / prefix)) +appdata-dst := base-dir / 'share' / 'appdata' / appdata +bin-dst := base-dir / 'bin' / name +desktop-dst := base-dir / 'share' / 'applications' / desktop +icons-dst := base-dir / 'share' / 'icons' / 'hicolor' +icon-svg-dst := icons-dst / 'scalable' / 'apps' + +# Default recipe which runs `just build-release` +default: build-release + +# Runs `cargo clean` +clean: + cargo clean + +# Removes vendored dependencies +clean-vendor: + rm -rf .cargo vendor vendor.tar + +# `cargo clean` and removes vendored dependencies +clean-dist: clean clean-vendor + +# Compiles with debug profile +build-debug *args: + cargo build {{args}} + +# Compiles with release profile +build-release *args: (build-debug '--release' args) + +# Compiles release profile with vendored dependencies +build-vendored *args: vendor-extract (build-release '--frozen --offline' args) + +# Runs a clippy check +check *args: + cargo clippy --all-features {{args}} -- -W clippy::pedantic + +# Runs a clippy check with JSON message format +check-json: (check '--message-format=json') + +# Run the application for testing purposes +run *args: + env RUST_BACKTRACE=full cargo run --release {{args}} + +# Installs files +install: + install -Dm0755 {{ cargo-target-dir / 'release' / name }} {{bin-dst}} + install -Dm0644 {{ 'resources' / desktop }} {{desktop-dst}} + install -Dm0644 {{ 'resources' / appdata }} {{appdata-dst}} + install -Dm0644 {{ 'resources' / 'icons' / 'hicolor' / 'scalable' / 'apps' / 'icon.svg' }} {{icon-svg-dst}} + +# Uninstalls installed files +uninstall: + rm {{bin-dst}} {{desktop-dst}} {{icon-svg-dst}} + +# Vendor dependencies locally +vendor: + mkdir -p .cargo + cargo vendor | head -n -1 > .cargo/config.toml + echo 'directory = "vendor"' >> .cargo/config.toml + tar pcf vendor.tar vendor + rm -rf vendor + +# Extracts vendored dependencies +vendor-extract: + rm -rf vendor + tar pxf vendor.tar + +# Bump cargo version, create git commit, and create tag +tag version: + find -type f -name Cargo.toml -exec sed -i '0,/^version/s/^version.*/version = "{{version}}"/' '{}' \; -exec git add '{}' \; + cargo check + cargo clean + git add Cargo.lock + git commit -m 'release: {{version}}' + git commit --amend + git tag -a {{version}} -m '' + diff --git a/resources/app.desktop b/resources/app.desktop new file mode 100644 index 0000000..b36894b --- /dev/null +++ b/resources/app.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=Cosmic Weather Applet +Comment=Weather applet for COSMIC +Type=Application +Icon=com.github.elerch.cosmic-weather-applet +Exec=cosmic-weather-applet %F +Terminal=false +StartupNotify=true +Categories=COSMIC +Keywords=COSMIC +MimeType= diff --git a/resources/app.metainfo.xml b/resources/app.metainfo.xml new file mode 100644 index 0000000..4755522 --- /dev/null +++ b/resources/app.metainfo.xml @@ -0,0 +1,35 @@ + + + com.github.elerch.cosmic-weather-applet + CC0-1.0 + MIT + Cosmic Weather Applet + Weather applet for COSMIC + + https://git.lerch.org/lobo/cosmic-weather-applet/raw/main/resources/icons/hicolor/scalable/apps/icon.svg + + https://git.lerch.org/lobo/cosmic-weather-applet + com.github.elerch.cosmic-weather-applet.desktop + + com.github.elerch.cosmic-weather-applet + + cosmic-weather-applet + + + + 360 + + + keyboard + pointing + touch + + + COSMIC + + + COSMIC + + + + diff --git a/resources/icons/hicolor/scalable/apps/icon.svg b/resources/icons/hicolor/scalable/apps/icon.svg new file mode 100644 index 0000000..36c2f5a --- /dev/null +++ b/resources/icons/hicolor/scalable/apps/icon.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d0c1744 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: MIT + +use crate::config::Config; +use crate::fl; +use cosmic::app::context_drawer; +use cosmic::cosmic_config::{self, CosmicConfigEntry}; +use cosmic::iced::alignment::{Horizontal, Vertical}; +use cosmic::iced::{Alignment, Length, Subscription}; +use cosmic::widget::{self, about::About, icon, menu, nav_bar}; +use cosmic::{iced_futures, prelude::*}; +use futures_util::SinkExt; +use std::collections::HashMap; +use std::time::Duration; + +const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); +const APP_ICON: &[u8] = include_bytes!("../resources/icons/hicolor/scalable/apps/icon.svg"); + +/// The application model stores app-specific state used to describe its interface and +/// drive its logic. +pub struct AppModel { + /// Application state which is managed by the COSMIC runtime. + core: cosmic::Core, + /// Display a context drawer with the designated page if defined. + context_page: ContextPage, + /// The about page for this app. + about: About, + /// Contains items assigned to the nav bar panel. + nav: nav_bar::Model, + /// Key bindings for the application's menu bar. + key_binds: HashMap, + /// Configuration data that persists between application runs. + config: Config, + /// Time active + time: u32, + /// Toggle the watch subscription + watch_is_active: bool, +} + +/// Messages emitted by the application and its widgets. +#[derive(Debug, Clone)] +pub enum Message { + LaunchUrl(String), + ToggleContextPage(ContextPage), + ToggleWatch, + UpdateConfig(Config), + WatchTick(u32), +} + +/// Create a COSMIC application from the app model +impl cosmic::Application for AppModel { + /// The async executor that will be used to run your application's commands. + type Executor = cosmic::executor::Default; + + /// Data that your application receives to its init method. + type Flags = (); + + /// Messages which the application and its widgets will emit. + type Message = Message; + + /// Unique identifier in RDNN (reverse domain name notation) format. + const APP_ID: &'static str = "dev.mmurphy.Test"; + + fn core(&self) -> &cosmic::Core { + &self.core + } + + fn core_mut(&mut self) -> &mut cosmic::Core { + &mut self.core + } + + /// Initializes the application with any given flags and startup commands. + fn init( + core: cosmic::Core, + _flags: Self::Flags, + ) -> (Self, Task>) { + // Create a nav bar with three page items. + let mut nav = nav_bar::Model::default(); + + nav.insert() + .text(fl!("page-id", num = 1)) + .data::(Page::Page1) + .icon(icon::from_name("applications-science-symbolic")) + .activate(); + + nav.insert() + .text(fl!("page-id", num = 2)) + .data::(Page::Page2) + .icon(icon::from_name("applications-system-symbolic")); + + nav.insert() + .text(fl!("page-id", num = 3)) + .data::(Page::Page3) + .icon(icon::from_name("applications-games-symbolic")); + + // Create the about widget + let about = About::default() + .name(fl!("app-title")) + .icon(widget::icon::from_svg_bytes(APP_ICON)) + .version(env!("CARGO_PKG_VERSION")) + .links([(fl!("repository"), REPOSITORY)]) + .license(env!("CARGO_PKG_LICENSE")); + + // Construct the app model with the runtime's core. + let mut app = AppModel { + core, + context_page: ContextPage::default(), + about, + nav, + key_binds: HashMap::new(), + // Optional configuration file for an application. + config: cosmic_config::Config::new(Self::APP_ID, Config::VERSION) + .map(|context| match Config::get_entry(&context) { + Ok(config) => config, + Err((_errors, config)) => { + // for why in errors { + // tracing::error!(%why, "error loading app config"); + // } + + config + } + }) + .unwrap_or_default(), + time: 0, + watch_is_active: false, + }; + + // Create a startup command that sets the window title. + let command = app.update_title(); + + (app, command) + } + + /// Elements to pack at the start of the header bar. + fn header_start(&self) -> Vec> { + let menu_bar = menu::bar(vec![menu::Tree::with_children( + menu::root(fl!("view")).apply(Element::from), + menu::items( + &self.key_binds, + vec![menu::Item::Button(fl!("about"), None, MenuAction::About)], + ), + )]); + + vec![menu_bar.into()] + } + + /// Enables the COSMIC application to create a nav bar with this model. + fn nav_model(&self) -> Option<&nav_bar::Model> { + Some(&self.nav) + } + + /// Display a context drawer if the context page is requested. + fn context_drawer(&self) -> Option> { + if !self.core.window.show_context { + return None; + } + + Some(match self.context_page { + ContextPage::About => context_drawer::about( + &self.about, + |url| Message::LaunchUrl(url.to_string()), + Message::ToggleContextPage(ContextPage::About), + ), + }) + } + + /// Describes the interface based on the current state of the application model. + /// + /// Application events will be processed through the view. Any messages emitted by + /// events received by widgets will be passed to the update method. + fn view(&self) -> Element<'_, Self::Message> { + let space_s = cosmic::theme::spacing().space_s; + let content: Element<_> = match self.nav.active_data::().unwrap() { + Page::Page1 => { + let header = widget::row::with_capacity(2) + .push(widget::text::title1(fl!("welcome"))) + .push(widget::text::title3(fl!("page-id", num = 1))) + .align_y(Alignment::End) + .spacing(space_s); + + let counter_label = ["Watch: ", self.time.to_string().as_str()].concat(); + let section = cosmic::widget::settings::section().add( + cosmic::widget::settings::item::builder(counter_label).control( + widget::button::text(if self.watch_is_active { + "Stop" + } else { + "Start" + }) + .on_press(Message::ToggleWatch), + ), + ); + + widget::column::with_capacity(2) + .push(header) + .push(section) + .spacing(space_s) + .height(Length::Fill) + .into() + } + + Page::Page2 => { + let header = widget::row::with_capacity(2) + .push(widget::text::title1(fl!("welcome"))) + .push(widget::text::title3(fl!("page-id", num = 2))) + .align_y(Alignment::End) + .spacing(space_s); + + widget::column::with_capacity(1) + .push(header) + .spacing(space_s) + .height(Length::Fill) + .into() + } + + Page::Page3 => { + let header = widget::row::with_capacity(2) + .push(widget::text::title1(fl!("welcome"))) + .push(widget::text::title3(fl!("page-id", num = 3))) + .align_y(Alignment::End) + .spacing(space_s); + + widget::column::with_capacity(1) + .push(header) + .spacing(space_s) + .height(Length::Fill) + .into() + } + }; + + widget::container(content) + .width(600) + .height(Length::Fill) + .apply(widget::container) + .width(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .into() + } + + /// Register subscriptions for this application. + /// + /// Subscriptions are long-running async tasks running in the background which + /// emit messages to the application through a channel. They can be dynamically + /// stopped and started conditionally based on application state, or persist + /// indefinitely. + fn subscription(&self) -> Subscription { + // Add subscriptions which are always active. + let mut subscriptions = vec![ + // Watch for application configuration changes. + self.core() + .watch_config::(Self::APP_ID) + .map(|update| { + // for why in update.errors { + // tracing::error!(?why, "app config error"); + // } + + Message::UpdateConfig(update.config) + }), + ]; + + // Conditionally enables a timer that emits a message every second. + if self.watch_is_active { + subscriptions.push(Subscription::run(|| { + iced_futures::stream::channel(1, |mut emitter| async move { + let mut time = 1; + let mut interval = tokio::time::interval(Duration::from_secs(1)); + + loop { + interval.tick().await; + _ = emitter.send(Message::WatchTick(time)).await; + time += 1; + } + }) + })); + } + + Subscription::batch(subscriptions) + } + + /// Handles messages emitted by the application and its widgets. + /// + /// Tasks may be returned for asynchronous execution of code in the background + /// on the application's async runtime. + fn update(&mut self, message: Self::Message) -> Task> { + match message { + Message::WatchTick(time) => { + self.time = time; + } + + Message::ToggleWatch => { + self.watch_is_active = !self.watch_is_active; + } + + Message::ToggleContextPage(context_page) => { + if self.context_page == context_page { + // Close the context drawer if the toggled context page is the same. + self.core.window.show_context = !self.core.window.show_context; + } else { + // Open the context drawer to display the requested context page. + self.context_page = context_page; + self.core.window.show_context = true; + } + } + + Message::UpdateConfig(config) => { + self.config = config; + } + + Message::LaunchUrl(url) => match open::that_detached(&url) { + Ok(()) => {} + Err(err) => { + eprintln!("failed to open {url:?}: {err}"); + } + }, + } + Task::none() + } + + /// Called when a nav item is selected. + fn on_nav_select(&mut self, id: nav_bar::Id) -> Task> { + // Activate the page in the model. + self.nav.activate(id); + + self.update_title() + } +} + +impl AppModel { + /// Updates the header and window titles. + pub fn update_title(&mut self) -> Task> { + let mut window_title = fl!("app-title"); + + if let Some(page) = self.nav.text(self.nav.active()) { + window_title.push_str(" — "); + window_title.push_str(page); + } + + if let Some(id) = self.core.main_window_id() { + self.set_window_title(window_title, id) + } else { + Task::none() + } + } +} + +/// The page to display in the application. +pub enum Page { + Page1, + Page2, + Page3, +} + +/// The context page to display in the context drawer. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum ContextPage { + #[default] + About, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MenuAction { + About, +} + +impl menu::action::MenuAction for MenuAction { + type Message = Message; + + fn message(&self) -> Self::Message { + match self { + MenuAction::About => Message::ToggleContextPage(ContextPage::About), + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f0bac1c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT + +use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry}; + +#[derive(Debug, Default, Clone, CosmicConfigEntry, Eq, PartialEq)] +#[version = 1] +pub struct Config { + demo: String, +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..40ba9b0 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT + +//! Provides localization support for this crate. + +use i18n_embed::{ + DefaultLocalizer, LanguageLoader, Localizer, + fluent::{FluentLanguageLoader, fluent_language_loader}, + unic_langid::LanguageIdentifier, +}; +use rust_embed::RustEmbed; +use std::sync::LazyLock; + +/// Applies the requested language(s) to requested translations from the `fl!()` macro. +pub fn init(requested_languages: &[LanguageIdentifier]) { + if let Err(why) = localizer().select(requested_languages) { + eprintln!("error while loading fluent localizations: {why}"); + } +} + +// Get the `Localizer` to be used for localizing this library. +#[must_use] +pub fn localizer() -> Box { + Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations)) +} + +#[derive(RustEmbed)] +#[folder = "i18n/"] +struct Localizations; + +pub static LANGUAGE_LOADER: LazyLock = LazyLock::new(|| { + let loader: FluentLanguageLoader = fluent_language_loader!(); + + loader + .load_fallback_language(&Localizations) + .expect("Error while loading fallback language"); + + loader +}); + + +/// Request a localized string by ID from the i18n/ directory. +#[macro_export] +macro_rules! fl { + ($message_id:literal) => {{ + i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id) + }}; + + ($message_id:literal, $($args:expr),*) => {{ + i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, $($args), *) + }}; +} + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a39c9d6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +mod app; +mod config; +mod i18n; + +fn main() -> cosmic::iced::Result { + // Get the system's preferred languages. + let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages(); + + // Enable localizations to be applied. + i18n::init(&requested_languages); + + // Settings for configuring the application window and iced runtime. + let settings = cosmic::app::Settings::default().size_limits( + cosmic::iced::Limits::NONE + .min_width(360.0) + .min_height(180.0), + ); + + // Starts the application's event loop with `()` as the application's flags. + cosmic::app::run::(settings, ()) +}