cargo generate gh:pop-os/cosmic-app-template

This commit is contained in:
Emil Lerch 2026-01-20 14:30:28 -08:00
parent ff079b8fc2
commit 93799798f0
Signed by: lobo
GPG key ID: A7B62D657EF764F8
15 changed files with 741 additions and 0 deletions

14
.gitignore vendored Normal file
View file

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

3
.mise.toml Normal file
View file

@ -0,0 +1,3 @@
[tools]
rust = "1.92.0"
rust-analyzer = "latest"

48
Cargo.toml Normal file
View file

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

21
LICENSE Normal file
View file

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

44
README.md Normal file
View file

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

4
i18n.toml Normal file
View file

@ -0,0 +1,4 @@
fallback_language = "en"
[fluent]
assets_dir = "i18n"

View file

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

96
justfile Normal file
View file

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

11
resources/app.desktop Normal file
View file

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

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.github.elerch.cosmic-weather-applet</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>Cosmic Weather Applet</name>
<summary>Weather applet for COSMIC</summary>
<icon type="remote" width="64" height="64" scale="1">
https://git.lerch.org/lobo/cosmic-weather-applet/raw/main/resources/icons/hicolor/scalable/apps/icon.svg
</icon>
<url type="vcs-browser">https://git.lerch.org/lobo/cosmic-weather-applet</url>
<launchable type="desktop-id">com.github.elerch.cosmic-weather-applet.desktop</launchable>
<provides>
<id>com.github.elerch.cosmic-weather-applet</id>
<binaries>
<binary>cosmic-weather-applet</binary>
</binaries>
</provides>
<requires>
<display_length compare="ge">360</display_length>
</requires>
<supports>
<control>keyboard</control>
<control>pointing</control>
<control>touch</control>
</supports>
<categories>
<category>COSMIC</category>
</categories>
<keywords>
<keyword>COSMIC</keyword>
</keywords>
<content_rating type="oars-1.1" />
</component>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"/>

After

Width:  |  Height:  |  Size: 102 B

372
src/app.rs Normal file
View file

@ -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<menu::KeyBind, MenuAction>,
/// 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<cosmic::Action<Self::Message>>) {
// 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>(Page::Page1)
.icon(icon::from_name("applications-science-symbolic"))
.activate();
nav.insert()
.text(fl!("page-id", num = 2))
.data::<Page>(Page::Page2)
.icon(icon::from_name("applications-system-symbolic"));
nav.insert()
.text(fl!("page-id", num = 3))
.data::<Page>(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<Element<'_, Self::Message>> {
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<context_drawer::ContextDrawer<'_, Self::Message>> {
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::<Page>().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<Self::Message> {
// Add subscriptions which are always active.
let mut subscriptions = vec![
// Watch for application configuration changes.
self.core()
.watch_config::<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<cosmic::Action<Self::Message>> {
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<cosmic::Action<Self::Message>> {
// 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<cosmic::Action<Message>> {
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),
}
}
}

9
src/config.rs Normal file
View file

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

52
src/i18n.rs Normal file
View file

@ -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<dyn Localizer> {
Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations))
}
#[derive(RustEmbed)]
#[folder = "i18n/"]
struct Localizations;
pub static LANGUAGE_LOADER: LazyLock<FluentLanguageLoader> = 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), *)
}};
}

23
src/main.rs Normal file
View file

@ -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::<app::AppModel>(settings, ())
}