├── crates ├── backend │ ├── build.rs │ ├── README.md │ ├── src │ │ ├── managers │ │ │ ├── mod.rs │ │ │ ├── window_manager │ │ │ │ ├── cache.rs │ │ │ │ └── buffer.rs │ │ │ ├── idle_manager.rs │ │ │ ├── idle_manager │ │ │ │ └── idle_notifier.rs │ │ │ └── window_manager.rs │ │ ├── error.rs │ │ ├── dispatcher.rs │ │ └── scheduler.rs │ ├── .gitignore │ └── Cargo.toml ├── config │ ├── build.rs │ ├── .gitignore │ ├── README.md │ ├── Cargo.toml │ └── src │ │ ├── theme.rs │ │ ├── general.rs │ │ ├── color.rs │ │ ├── text.rs │ │ ├── sorting.rs │ │ ├── display.rs │ │ └── spacing.rs ├── dbus │ ├── build.rs │ ├── README.md │ ├── src │ │ ├── lib.rs │ │ ├── actions.rs │ │ ├── client.rs │ │ ├── image.rs │ │ ├── server.rs │ │ └── notification.rs │ ├── .gitignore │ └── Cargo.toml ├── shared │ ├── src │ │ ├── lib.rs │ │ ├── error.rs │ │ ├── file_descriptor.rs │ │ ├── cached_data.rs │ │ ├── value.rs │ │ └── file_watcher.rs │ └── Cargo.toml ├── client │ ├── .gitignore │ ├── README.md │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── filetype │ ├── .gitignore │ ├── README.md │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ ├── layout.pest │ │ └── parser.rs ├── macros │ ├── .gitignore │ ├── Cargo.toml │ ├── src │ │ ├── lib.rs │ │ └── general.rs │ ├── tests │ │ ├── config_property.rs │ │ └── generic_builder.rs │ └── README.md ├── render │ ├── src │ │ ├── lib.rs │ │ ├── types.rs │ │ ├── widget.rs │ │ ├── drawer.rs │ │ ├── widget │ │ │ └── image.rs │ │ └── color.rs │ ├── README.md │ └── Cargo.toml └── app │ ├── main.rs │ └── cli.rs ├── .gitignore ├── assets ├── logo512.png └── logo512.svg ├── .github ├── workflows │ ├── labeler.yml │ ├── rustfmt-check.yml │ ├── rust-test.yml │ ├── rust-build.yml │ └── rust-clippy.yml └── labeler.yml ├── rustfmt.toml ├── Cargo.toml ├── schemas └── introspection.xml └── README.md /crates/backend/build.rs: -------------------------------------------------------------------------------- 1 | ../../build.rs -------------------------------------------------------------------------------- /crates/config/build.rs: -------------------------------------------------------------------------------- 1 | ../../build.rs -------------------------------------------------------------------------------- /crates/dbus/build.rs: -------------------------------------------------------------------------------- 1 | ../../build.rs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | output.png 4 | src/utils.rs 5 | debug.log 6 | -------------------------------------------------------------------------------- /assets/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noti-rs/noti/HEAD/assets/logo512.png -------------------------------------------------------------------------------- /crates/backend/README.md: -------------------------------------------------------------------------------- 1 | # backend 2 | 3 | The noti crate which works as daemon. 4 | -------------------------------------------------------------------------------- /crates/dbus/README.md: -------------------------------------------------------------------------------- 1 | # dbus 2 | 3 | The main dbus actions happens here with `zbus` crate. 4 | -------------------------------------------------------------------------------- /crates/backend/src/managers/mod.rs: -------------------------------------------------------------------------------- 1 | pub(super) mod idle_manager; 2 | pub(super) mod window_manager; 3 | -------------------------------------------------------------------------------- /crates/shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cached_data; 2 | pub mod error; 3 | pub mod file_descriptor; 4 | pub mod file_watcher; 5 | pub mod value; 6 | -------------------------------------------------------------------------------- /crates/dbus/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod actions; 2 | pub mod client; 3 | pub mod image; 4 | pub mod notification; 5 | pub mod server; 6 | pub mod text; 7 | -------------------------------------------------------------------------------- /crates/dbus/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | output.png 4 | src/utils.rs 5 | debug.log 6 | 7 | # Don't save .lock files in workspaces 8 | Cargo.lock 9 | -------------------------------------------------------------------------------- /crates/backend/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | output.png 4 | src/utils.rs 5 | debug.log 6 | 7 | # Don't save .lock files in workspaces 8 | Cargo.lock 9 | -------------------------------------------------------------------------------- /crates/client/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | output.png 4 | src/utils.rs 5 | debug.log 6 | 7 | # Don't save .lock files in workspaces 8 | Cargo.lock 9 | -------------------------------------------------------------------------------- /crates/config/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | output.png 4 | src/utils.rs 5 | debug.log 6 | 7 | # Don't save .lock files in workspaces 8 | Cargo.lock 9 | -------------------------------------------------------------------------------- /crates/filetype/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | output.png 4 | src/utils.rs 5 | debug.log 6 | 7 | # Don't save .lock files in workspaces 8 | Cargo.lock 9 | -------------------------------------------------------------------------------- /crates/macros/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | output.png 4 | src/utils.rs 5 | debug.log 6 | 7 | # Don't save .lock files in workspaces 8 | Cargo.lock 9 | -------------------------------------------------------------------------------- /crates/render/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod color; 2 | pub mod drawer; 3 | pub mod image; 4 | pub mod types; 5 | pub mod widget; 6 | 7 | pub use widget::text::PangoContext; 8 | -------------------------------------------------------------------------------- /crates/render/README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | The `render` rust crate which maded for **Noti** notification system. 4 | This crate provides way to render banners or something other using widgets. 5 | -------------------------------------------------------------------------------- /crates/config/README.md: -------------------------------------------------------------------------------- 1 | # config 2 | 3 | The config crate for `noti` backend and client. 4 | 5 | This crate have some features like `watch-mode` that can detect changes of config file and reloads when needed. 6 | -------------------------------------------------------------------------------- /crates/client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | The client which can interact with the `org.freedesktop.Notifications` dbus endpoint. It have some features for `noti` application. 4 | 5 | ## State 6 | 7 | It's not ready and the work in progress. 8 | -------------------------------------------------------------------------------- /crates/filetype/README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | The `filetype` rust crate is part of the notification system **Noti**. 4 | This crate contains only the methods which will work with `.noti` filetypes. 5 | 6 | Usually it uses in backend for banner rendering because of custom layout. 7 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | - pull_request_target 4 | 5 | jobs: 6 | labeler: 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/labeler@v5 13 | -------------------------------------------------------------------------------- /crates/shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shared" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | log.workspace = true 9 | anyhow.workspace = true 10 | derive_more.workspace = true 11 | 12 | inotify = "0.11.0" 13 | -------------------------------------------------------------------------------- /crates/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "client" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | dbus.workspace = true 9 | 10 | anyhow.workspace = true 11 | async-io.workspace = true 12 | log.workspace = true 13 | zbus.workspace = true 14 | -------------------------------------------------------------------------------- /crates/macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "macros" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | 7 | [lib] 8 | proc-macro = true 9 | 10 | [dependencies] 11 | proc-macro2 = "1.0.86" 12 | quote = "1.0.36" 13 | syn = "2.0.72" 14 | 15 | [dev-dependencies] 16 | shared.workspace = true 17 | -------------------------------------------------------------------------------- /crates/config/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "config" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | dbus.workspace = true 9 | macros.workspace = true 10 | shared.workspace = true 11 | 12 | anyhow.workspace = true 13 | log.workspace = true 14 | humantime.workspace = true 15 | 16 | serde = "1.0.205" 17 | toml = "0.8.19" 18 | shellexpand = "3.1.0" 19 | glob = "0.3.1" 20 | -------------------------------------------------------------------------------- /crates/dbus/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dbus" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | anyhow.workspace = true 9 | async-io.workspace = true 10 | async-channel.workspace = true 11 | log.workspace = true 12 | zbus.workspace = true 13 | derive_more.workspace = true 14 | tempfile.workspace = true 15 | shared.workspace = true 16 | 17 | html-escape = "0.2.13" 18 | unic-segment = "0.9.0" 19 | -------------------------------------------------------------------------------- /crates/filetype/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "filetype" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | shared.workspace = true 9 | render.workspace = true 10 | config.workspace = true 11 | 12 | anyhow.workspace = true 13 | log.workspace = true 14 | 15 | pest = { version = "2.7.14", features = ["miette-error", "pretty-print"] } 16 | pest_derive = { version = "2.7.14", features = ["grammar-extras"] } 17 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/rust-lang/rustfmt/blob/master/Configurations.md 2 | 3 | max_width = 100 4 | reorder_imports = true 5 | edition = "2021" 6 | fn_params_layout = "Tall" 7 | fn_call_width = 60 8 | force_explicit_abi = true 9 | hard_tabs = false 10 | match_arm_leading_pipes = "Never" 11 | match_block_trailing_comma = false 12 | merge_derives = true 13 | newline_style = "Unix" 14 | single_line_let_else_max_width = 0 15 | tab_spaces = 4 16 | use_field_init_shorthand = true 17 | -------------------------------------------------------------------------------- /.github/workflows/rustfmt-check.yml: -------------------------------------------------------------------------------- 1 | name: Rustfmt check 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: cargo fmt --check 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: dtolnay/rust-toolchain@stable 19 | with: 20 | components: rustfmt 21 | - run: cargo fmt --check 22 | -------------------------------------------------------------------------------- /crates/backend/src/error.rs: -------------------------------------------------------------------------------- 1 | use dbus::notification::Notification; 2 | 3 | pub(crate) enum Error { 4 | UnrenderedNotifications(Vec), 5 | Fatal(anyhow::Error), 6 | } 7 | 8 | impl From for Error { 9 | fn from(value: anyhow::Error) -> Self { 10 | Self::Fatal(value) 11 | } 12 | } 13 | 14 | impl From> for Error { 15 | fn from(value: Vec) -> Self { 16 | Self::UnrenderedNotifications(value) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /crates/app/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | 3 | use clap::Parser; 4 | 5 | use cli::Args; 6 | 7 | #[cfg(feature = "libc_alloc")] 8 | #[global_allocator] 9 | static GLOBAL: libc_alloc::LibcAlloc = libc_alloc::LibcAlloc; 10 | 11 | fn main() -> anyhow::Result<()> { 12 | setup_logger(); 13 | 14 | let args = Args::parse(); 15 | args.process() 16 | } 17 | 18 | fn setup_logger() { 19 | const ENV_NAME: &str = "NOTI_LOG"; 20 | env_logger::Builder::from_env(env_logger::Env::default().filter_or(ENV_NAME, "info")).init(); 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/rust-test.yml: -------------------------------------------------------------------------------- 1 | name: Rust test 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | test: 11 | name: cargo test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Update apt-get 15 | run: sudo apt-get update 16 | - name: Install pangocairo 17 | run: sudo apt-get install librust-pangocairo-sys-dev 18 | - uses: actions/checkout@v4 19 | - uses: dtolnay/rust-toolchain@stable 20 | - run: cargo test --all-features 21 | -------------------------------------------------------------------------------- /.github/workflows/rust-build.yml: -------------------------------------------------------------------------------- 1 | name: Rust build 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: cargo build 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Update apt-get 18 | run: sudo apt-get update 19 | - name: Install pangocairo 20 | run: sudo apt-get install librust-pangocairo-sys-dev 21 | - uses: actions/checkout@v4 22 | - uses: dtolnay/rust-toolchain@stable 23 | - run: cargo build --all-features 24 | -------------------------------------------------------------------------------- /crates/render/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "render" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | 7 | [dependencies] 8 | config.workspace = true 9 | dbus.workspace = true 10 | shared.workspace = true 11 | macros.workspace = true 12 | tempfile.workspace = true 13 | 14 | log.workspace = true 15 | anyhow.workspace = true 16 | derive_more.workspace = true 17 | derive_builder.workspace = true 18 | 19 | resvg = "0.45.1" 20 | image = { version = "0.25.2", features = ["png"] } 21 | pangocairo = "0.21.0" 22 | cairo-rs = { version = "0.21.0", features = ["png", "svg"] } 23 | linicon = "2.3.0" 24 | -------------------------------------------------------------------------------- /.github/workflows/rust-clippy.yml: -------------------------------------------------------------------------------- 1 | name: Rust clippy check 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: cargo clippy 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Update apt-get 18 | run: sudo apt-get update 19 | - name: Install pangocairo 20 | run: sudo apt-get install librust-pangocairo-sys-dev 21 | - uses: actions/checkout@v4 22 | - uses: dtolnay/rust-toolchain@stable 23 | with: 24 | components: clippy 25 | - run: cargo clippy 26 | env: 27 | RUSTFLAGS: "-Dwarnings" 28 | -------------------------------------------------------------------------------- /crates/macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod config_property; 2 | mod general; 3 | mod generic_builder; 4 | 5 | use proc_macro::TokenStream; 6 | 7 | macro_rules! propagate_err { 8 | ($expr:expr) => { 9 | match $expr { 10 | Ok(data) => data, 11 | Err(err) => return err.to_compile_error().into(), 12 | } 13 | }; 14 | } 15 | 16 | pub(crate) use propagate_err; 17 | 18 | #[proc_macro_derive(ConfigProperty, attributes(cfg_prop))] 19 | pub fn config_property(item: TokenStream) -> TokenStream { 20 | config_property::make_derive(item) 21 | } 22 | 23 | #[proc_macro_derive(GenericBuilder, attributes(gbuilder))] 24 | pub fn generic_builder(item: TokenStream) -> TokenStream { 25 | generic_builder::make_derive(item) 26 | } 27 | -------------------------------------------------------------------------------- /crates/shared/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::value::Value; 2 | 3 | #[derive(Debug, derive_more::Display)] 4 | pub enum ConversionError { 5 | #[display("The '{field_name}' field is unknown")] 6 | UnknownField { field_name: String, value: Value }, 7 | #[display("Unsupported constructor")] 8 | UnsupportedConstructor, 9 | #[display("Provided invalid value. Expected [{expected}], but given [{actual}]")] 10 | InvalidValue { 11 | expected: &'static str, 12 | actual: String, 13 | }, 14 | #[display("Cannot convert the current type into specific")] 15 | CannotConvert, 16 | #[display("The boxed Any type cannot be downcasted into concrete type: {concrete_typename}")] 17 | AnyDoesntMatchType { concrete_typename: &'static str }, 18 | } 19 | 20 | impl std::error::Error for ConversionError {} 21 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | App: 2 | - changed-files: 3 | - any-glob-to-any-file: 'crates/app/**' 4 | 5 | Backend: 6 | - changed-files: 7 | - any-glob-to-any-file: 'crates/backend/**' 8 | 9 | Render: 10 | - changed-files: 11 | - any-glob-to-any-file: 'crates/render/**' 12 | 13 | Config: 14 | - changed-files: 15 | - any-glob-to-any-file: 'crates/config/**' 16 | 17 | Macros: 18 | - changed-files: 19 | - any-glob-to-any-file: 'crates/macros/**' 20 | 21 | DBus: 22 | - changed-files: 23 | - any-glob-to-any-file: 'crates/dbus/**' 24 | 25 | Client: 26 | - changed-files: 27 | - any-glob-to-any-file: 'crates/client/**' 28 | 29 | Filetype: 30 | - changed-files: 31 | - any-glob-to-any-file: 'crates/filetype/**' 32 | 33 | Shared: 34 | - changed-files: 35 | - any-glob-to-any-file: 'crates/shared/**' 36 | 37 | Documentation: 38 | - changed-files: 39 | - any-glob-to-any-file: 'docs/**' 40 | -------------------------------------------------------------------------------- /crates/backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "backend" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | 7 | [features] 8 | default = ["libc_alloc"] 9 | libc_alloc = ["dep:libc"] 10 | 11 | [dependencies] 12 | dbus.workspace = true 13 | config.workspace = true 14 | render.workspace = true 15 | shared.workspace = true 16 | filetype.path = "../filetype" 17 | 18 | anyhow.workspace = true 19 | async-io.workspace = true 20 | async-channel.workspace = true 21 | log.workspace = true 22 | humantime.workspace = true 23 | tempfile.workspace = true 24 | derive_builder.workspace = true 25 | 26 | wayland-client = "0.31.10" 27 | wayland-protocols = { version = "0.32.8", features = ["client", "wayland-client", "staging", "unstable"] } 28 | wayland-protocols-wlr = { version = "0.3.8", features = ["client", "wayland-client"] } 29 | indexmap = "2.4.0" 30 | chrono = "0.4.39" 31 | libc = { version = "0.2.174", optional = true } 32 | -------------------------------------------------------------------------------- /crates/backend/src/dispatcher.rs: -------------------------------------------------------------------------------- 1 | use wayland_client::EventQueue; 2 | 3 | pub trait Dispatcher { 4 | type State; 5 | 6 | fn get_event_queue_and_state( 7 | &mut self, 8 | ) -> Option<(&mut EventQueue, &mut Self::State)>; 9 | 10 | fn dispatch(&mut self) -> anyhow::Result { 11 | let (event_queue, state) = match self.get_event_queue_and_state() { 12 | Some(queue) => queue, 13 | None => return Ok(false), 14 | }; 15 | 16 | let dispatched_count = event_queue.dispatch_pending(state)?; 17 | 18 | if dispatched_count > 0 { 19 | return Ok(true); 20 | } 21 | 22 | event_queue.flush()?; 23 | let Some(guard) = event_queue.prepare_read() else { 24 | return Ok(false); 25 | }; 26 | let Ok(count) = guard.read() else { 27 | return Ok(false); 28 | }; 29 | 30 | Ok(if count > 0 { 31 | event_queue.dispatch_pending(state)?; 32 | true 33 | } else { 34 | false 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /crates/dbus/src/actions.rs: -------------------------------------------------------------------------------- 1 | use derive_more::derive::Display; 2 | 3 | use crate::notification::ScheduledNotification; 4 | 5 | use super::notification::Notification; 6 | 7 | pub enum Action { 8 | Show(Box), 9 | Schedule(ScheduledNotification), 10 | Close(Option), 11 | CloseAll, 12 | } 13 | 14 | #[derive(Display)] 15 | #[display("{_variant}")] 16 | pub enum Signal { 17 | #[display("notification_id: {notification_id}, action_key: {action_key}")] 18 | ActionInvoked { 19 | notification_id: u32, 20 | action_key: String, 21 | }, 22 | #[display("notification_id: {notification_id}, action_key: {reason}")] 23 | NotificationClosed { 24 | notification_id: u32, 25 | reason: ClosingReason, 26 | }, 27 | } 28 | 29 | #[derive(Display)] 30 | pub enum ClosingReason { 31 | Expired, 32 | DismissedByUser, 33 | CallCloseNotification, 34 | Undefined, 35 | } 36 | 37 | impl From for u32 { 38 | fn from(value: ClosingReason) -> Self { 39 | match value { 40 | ClosingReason::Expired => 1, 41 | ClosingReason::DismissedByUser => 2, 42 | ClosingReason::CallCloseNotification => 3, 43 | ClosingReason::Undefined => 4, 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /crates/filetype/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use render::widget::Widget; 4 | 5 | mod converter; 6 | mod parser; 7 | 8 | pub fn parse_layout(path: &Path) -> anyhow::Result { 9 | let data = std::fs::read_to_string(path)?; 10 | let pairs = parser::parse(&data)?; 11 | converter::convert_into_widgets(pairs) 12 | } 13 | 14 | #[test] 15 | fn minimal_type() { 16 | let pairs = parser::parse( 17 | r#" 18 | /*WImage( 19 | max_width = 3, 20 | max_height = 4, 21 | )*/ 22 | 23 | // WText(kind = summary) 24 | 25 | alias SizedText = Text(font_size = 20) 26 | alias DefaultAlignment = Alignment( 27 | horizontal = start, 28 | vertical = space_between, 29 | ) 30 | 31 | alias Summary = SizedText(kind = summary) 32 | alias Row = FlexContainer(direction = horizontal) 33 | 34 | Row( 35 | max_width = 400, 36 | max_height = 120, 37 | 38 | alignment = DefaultAlignment(), 39 | ) { 40 | Image( 41 | max_size = 86, 42 | ) 43 | Summary( 44 | wrap = false, 45 | line_spacing = 10, 46 | ) 47 | } 48 | "#, 49 | ) 50 | .unwrap(); 51 | converter::convert_into_widgets(pairs).unwrap(); 52 | } 53 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "noti" 3 | authors = [ "noti-rs" ] 4 | version = "0.1.0" 5 | edition = "2021" 6 | 7 | # Focus on min size of bin 8 | [profile.release] 9 | lto = true 10 | strip = true 11 | opt-level = "z" 12 | codegen-units = 1 13 | panic = "abort" 14 | debug = "none" 15 | debug-assertions = false 16 | 17 | [features] 18 | default = ["libc_alloc"] 19 | libc_alloc = ["dep:libc_alloc"] 20 | 21 | [[bin]] 22 | path = "crates/app/main.rs" 23 | name = "noti" 24 | 25 | [workspace] 26 | members = [ 27 | "crates/config", 28 | "crates/backend", 29 | "crates/client", 30 | "crates/dbus", 31 | "crates/macros", 32 | "crates/render", 33 | "crates/filetype", 34 | "crates/shared" 35 | ] 36 | 37 | [workspace.package] 38 | authors = [ "noti-rs" ] 39 | version = "0.1.0" 40 | edition = "2021" 41 | 42 | [workspace.dependencies] 43 | anyhow = "1.0.86" 44 | log = "0.4.22" 45 | zbus = "5.8.0" 46 | async-io = "2.4.1" 47 | async-channel = "2.5.0" 48 | derive_more = { version = "2.0.1", features = ["display"] } 49 | humantime = "2.1.0" 50 | tempfile = "3.12.0" 51 | derive_builder = "0.20.0" 52 | 53 | config.path = "crates/config" 54 | render.path = "crates/render" 55 | dbus.path = "crates/dbus" 56 | shared.path = "crates/shared" 57 | macros.path = "crates/macros" 58 | 59 | [dependencies] 60 | config.workspace = true 61 | backend.path = "crates/backend" 62 | client.path = "crates/client" 63 | 64 | anyhow.workspace = true 65 | async-io.workspace = true 66 | async-channel.workspace = true 67 | clap = { version = "4.5.7", features = ["derive"] } 68 | env_logger = "0.11.5" 69 | libc_alloc = { version = "1.0.7", optional = true } 70 | -------------------------------------------------------------------------------- /crates/config/src/theme.rs: -------------------------------------------------------------------------------- 1 | use dbus::notification::Urgency; 2 | use macros::ConfigProperty; 3 | use serde::Deserialize; 4 | 5 | use crate::{ 6 | color::{Color, Rgba}, 7 | public, 8 | }; 9 | 10 | public! { 11 | #[derive(ConfigProperty, Debug)] 12 | #[cfg_prop(name(TomlTheme), derive(Debug, Deserialize, Default))] 13 | struct Theme { 14 | name: String, 15 | 16 | #[cfg_prop(use_type(TomlColors))] 17 | low: Colors, 18 | 19 | #[cfg_prop(use_type(TomlColors))] 20 | normal: Colors, 21 | 22 | #[cfg_prop(use_type(TomlColors), default(path = TomlColors::default_critical))] 23 | critical: Colors, 24 | } 25 | } 26 | 27 | impl Theme { 28 | pub fn by_urgency(&self, urgency: &Urgency) -> &Colors { 29 | match urgency { 30 | Urgency::Low => &self.low, 31 | Urgency::Normal => &self.normal, 32 | Urgency::Critical => &self.critical, 33 | } 34 | } 35 | } 36 | 37 | impl Default for Theme { 38 | fn default() -> Self { 39 | TomlTheme::default().unwrap_or_default() 40 | } 41 | } 42 | 43 | public! { 44 | #[derive(ConfigProperty, Debug)] 45 | #[cfg_prop(name(TomlColors), derive(Debug, Clone, Deserialize, Default))] 46 | struct Colors { 47 | #[cfg_prop(default(path = Rgba::new_black))] 48 | foreground: Rgba, 49 | #[cfg_prop(default(path = Color::new_rgba_white))] 50 | background: Color, 51 | 52 | #[cfg_prop(default(path = Color::new_rgba_black))] 53 | border: Color, 54 | } 55 | } 56 | 57 | impl TomlColors { 58 | fn default_critical() -> TomlColors { 59 | TomlColors { 60 | background: Some(Color::new_rgba_white()), 61 | foreground: Some(Rgba::new_red()), 62 | border: Some(Color::new_rgba_red()), 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /crates/backend/src/managers/window_manager/cache.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use log::warn; 4 | use render::widget::Widget; 5 | use shared::{ 6 | cached_data::{CacheUpdate, CachedValueError}, 7 | file_watcher::{FileState, FilesWatcher}, 8 | }; 9 | 10 | pub(super) struct CachedLayout { 11 | watcher: FilesWatcher, 12 | layout: Option, 13 | } 14 | 15 | impl CachedLayout { 16 | pub(super) fn layout(&self) -> Option<&Widget> { 17 | self.layout.as_ref() 18 | } 19 | 20 | fn load_layout(path: &Path) -> Option { 21 | match filetype::parse_layout(path) { 22 | Ok(widget) => Some(widget), 23 | Err(err) => { 24 | warn!( 25 | "The layout by path {path} is not valid. Error: {err}", 26 | path = path.display() 27 | ); 28 | None 29 | } 30 | } 31 | } 32 | } 33 | 34 | impl CacheUpdate for CachedLayout { 35 | fn check_updates(&mut self) -> FileState { 36 | self.watcher.check_updates() 37 | } 38 | 39 | fn update(&mut self) { 40 | self.layout = self.watcher.get_watching_path().and_then(Self::load_layout) 41 | } 42 | } 43 | 44 | impl<'a> TryFrom<&'a PathBuf> for CachedLayout { 45 | type Error = CachedValueError; 46 | 47 | fn try_from(path_buf: &'a PathBuf) -> Result { 48 | let watcher = match FilesWatcher::init(vec![path_buf]) { 49 | Ok(watcher) => watcher, 50 | Err(err) => { 51 | return Err(CachedValueError::FailedInitWatcher { source: err }); 52 | } 53 | }; 54 | 55 | let layout = watcher 56 | .get_watching_path() 57 | .and_then(CachedLayout::load_layout); 58 | 59 | Ok(CachedLayout { watcher, layout }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /crates/filetype/src/layout.pest: -------------------------------------------------------------------------------- 1 | // BASE TOKENS 2 | // They are created because the Pest parser is not very smart 3 | // to figure out what to expect from next token while parsing 4 | // and with hardcoded values parser will throw error in other 5 | // place than needed when received text have mistakes. 6 | // 7 | // Also these rule should NOT be silent. The silent tokens 8 | // behaves like a hardcoded values. It's a deal with the Pest 9 | // parser. 10 | // 11 | // They will be here until the Pest parser gets better token 12 | // analyzing. 13 | 14 | Alias = { "alias" } 15 | 16 | OpeningBrace = { "{" } 17 | ClosingBrace = { "}" } 18 | 19 | OpeningParenthesis = { "(" } 20 | ClosingParenthesis = { ")" } 21 | 22 | Equal = { "=" } 23 | Comma = { "," } 24 | 25 | Hashtag = { "#" } 26 | 27 | // END BASE TOKENS 28 | 29 | Layout = { SOI ~ AliasDefinitions? ~ NodeType ~ EOI } 30 | 31 | AliasDefinitions = { AliasDefinition+ } 32 | AliasDefinition = { Alias ~ Identifier ~ Equal ~ TypeValue } 33 | 34 | NodeType = { 35 | Identifier ~ OpeningParenthesis 36 | ~ Properties? 37 | ~ ClosingParenthesis ~ (OpeningBrace 38 | ~ Children? 39 | ~ ClosingBrace)? 40 | } 41 | 42 | Children = { NodeType+ } 43 | 44 | Properties = { Property ~ (Comma ~ Property)* ~ Comma? } 45 | 46 | Property = { Identifier ~ Equal ~ PropertyValue } 47 | 48 | PropertyValue = { TypeValue | UInt | Literal } 49 | 50 | TypeValue = { 51 | Identifier ~ OpeningParenthesis 52 | ~ (Properties | PropertyValue)? 53 | ~ ClosingParenthesis } 54 | 55 | Literal = ${ Hashtag? ~ ASCII_ALPHANUMERIC ~ (ASCII_ALPHANUMERIC | "_" | "-")* } 56 | 57 | UInt = @{ ASCII_DIGIT+ } 58 | 59 | Identifier = ${ ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_")* } 60 | 61 | WHITESPACE = _{ " " | "\n" | "\t" } 62 | 63 | SingleLineComment = { "//" ~ (!"\n" ~ ANY)* } 64 | BlockComment = { "/*" ~ (!"*/" ~ ANY)* ~ "*/" } 65 | COMMENT = _{ SingleLineComment | BlockComment } 66 | -------------------------------------------------------------------------------- /schemas/introspection.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /crates/shared/src/file_descriptor.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::File, 3 | mem::ManuallyDrop, 4 | os::fd::{FromRawFd, IntoRawFd}, 5 | sync::Arc, 6 | }; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct FileDescriptor(Arc); 10 | 11 | impl FileDescriptor { 12 | pub fn get_file(&self) -> FileGuard { 13 | FileGuard::from_fd(self) 14 | } 15 | } 16 | 17 | impl From for FileDescriptor { 18 | fn from(value: File) -> Self { 19 | FileDescriptor(Arc::new(value.into_raw_fd())) 20 | } 21 | } 22 | 23 | impl Drop for FileDescriptor { 24 | fn drop(&mut self) { 25 | if Arc::strong_count(&self.0) == 1 { 26 | // SAFETY: the struct guarantees that there will not be a close for file if the count 27 | // of handlers more than one. So at last drop file will be closed. 28 | unsafe { 29 | File::from_raw_fd(*self.0); 30 | } 31 | } 32 | } 33 | } 34 | 35 | /// The file guard of file descriptor that guarantees not closing file. 36 | /// 37 | /// The main purpose of this guard for providing API for client code which should not close the 38 | /// file but able to read and write file. 39 | pub struct FileGuard { 40 | file: ManuallyDrop, 41 | _file_descriptor: FileDescriptor, 42 | } 43 | 44 | impl FileGuard { 45 | fn from_fd(file_descriptor: &FileDescriptor) -> Self { 46 | Self { 47 | file: ManuallyDrop::new(unsafe { File::from_raw_fd(*file_descriptor.0) }), 48 | _file_descriptor: file_descriptor.clone(), 49 | } 50 | } 51 | } 52 | 53 | impl std::ops::Deref for FileGuard { 54 | type Target = File; 55 | fn deref(&self) -> &Self::Target { 56 | &self.file 57 | } 58 | } 59 | 60 | impl std::ops::DerefMut for FileGuard { 61 | fn deref_mut(&mut self) -> &mut Self::Target { 62 | &mut self.file 63 | } 64 | } 65 | 66 | impl Drop for FileGuard { 67 | fn drop(&mut self) { 68 | let file = unsafe { ManuallyDrop::take(&mut self.file) }; 69 | let _fd = file.into_raw_fd(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /crates/backend/src/managers/idle_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::dispatcher::Dispatcher; 2 | use config::Config; 3 | use idle_notifier::{IdleNotifier, IdleState}; 4 | use log::debug; 5 | use wayland_client::{protocol::wl_seat::WlSeat, Connection, EventQueue}; 6 | use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notifier_v1::ExtIdleNotifierV1; 7 | 8 | mod idle_notifier; 9 | 10 | pub struct IdleManager { 11 | event_queue: EventQueue, 12 | pub idle_notifier: IdleNotifier, 13 | } 14 | 15 | impl Dispatcher for IdleManager { 16 | type State = IdleNotifier; 17 | 18 | fn get_event_queue_and_state( 19 | &mut self, 20 | ) -> Option<(&mut EventQueue, &mut Self::State)> { 21 | Some((&mut self.event_queue, &mut self.idle_notifier)) 22 | } 23 | } 24 | 25 | impl IdleManager { 26 | pub(crate) fn init

( 27 | wayland_connection: &Connection, 28 | protocols: &P, 29 | config: &Config, 30 | ) -> anyhow::Result 31 | where 32 | P: AsRef + AsRef, 33 | { 34 | let event_queue = wayland_connection.new_event_queue(); 35 | let idle_notifier = IdleNotifier::init(protocols, &event_queue.handle(), config)?; 36 | 37 | let idle_manager = Self { 38 | event_queue, 39 | idle_notifier, 40 | }; 41 | debug!("Idle Manager: Initialized"); 42 | 43 | Ok(idle_manager) 44 | } 45 | 46 | pub(crate) fn update_by_config

(&mut self, protocols: &P, config: &Config) 47 | where 48 | P: AsRef + AsRef, 49 | { 50 | self.idle_notifier 51 | .recreate(protocols, &self.event_queue.handle(), config); 52 | } 53 | 54 | pub(crate) fn reset_idle_state(&mut self) { 55 | self.idle_notifier.was_idled = false; 56 | } 57 | 58 | pub(crate) fn was_idled(&self) -> bool { 59 | self.idle_notifier.was_idled 60 | } 61 | 62 | pub(crate) fn is_idled(&self) -> bool { 63 | self.idle_notifier 64 | .idle_state 65 | .as_ref() 66 | .is_some_and(|state| matches!(state, IdleState::Idled)) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /crates/dbus/src/client.rs: -------------------------------------------------------------------------------- 1 | use log::debug; 2 | use std::collections::HashMap; 3 | use zbus::{proxy, zvariant::Value, Connection}; 4 | 5 | #[proxy( 6 | default_service = "org.freedesktop.Notifications", 7 | default_path = "/org/freedesktop/Notifications" 8 | )] 9 | pub trait Notifications { 10 | #[allow(clippy::too_many_arguments)] 11 | async fn notify( 12 | &self, 13 | app_name: &str, 14 | replaces_id: u32, 15 | app_icon: &str, 16 | summary: &str, 17 | body: &str, 18 | actions: Vec<&str>, 19 | hints: HashMap<&str, Value<'_>>, 20 | expire_timeout: i32, 21 | ) -> anyhow::Result; 22 | 23 | async fn get_server_information(&self) -> anyhow::Result<(String, String, String, String)>; 24 | } 25 | 26 | pub struct Client<'a> { 27 | proxy: NotificationsProxy<'a>, 28 | } 29 | 30 | impl Client<'_> { 31 | pub async fn init() -> anyhow::Result { 32 | debug!("D-Bus Client: Initializing"); 33 | let connection = Connection::session().await?; 34 | let proxy = NotificationsProxy::new(&connection).await?; 35 | 36 | debug!("D-Bus Client: Initialized"); 37 | Ok(Self { proxy }) 38 | } 39 | 40 | #[allow(clippy::too_many_arguments)] 41 | pub async fn notify( 42 | &self, 43 | app_name: &str, 44 | replaces_id: u32, 45 | app_icon: &str, 46 | summary: &str, 47 | body: &str, 48 | actions: Vec<&str>, 49 | hints: HashMap<&str, Value<'_>>, 50 | expire_timeout: i32, 51 | ) -> anyhow::Result { 52 | debug!("D-Bus Client: Trying to notify"); 53 | let reply = self 54 | .proxy 55 | .notify( 56 | app_name, 57 | replaces_id, 58 | app_icon, 59 | summary, 60 | body, 61 | actions, 62 | hints, 63 | expire_timeout, 64 | ) 65 | .await?; 66 | 67 | debug!("D-Bus Client: Notified"); 68 | Ok(reply) 69 | } 70 | 71 | pub async fn get_server_information(&self) -> anyhow::Result<(String, String, String, String)> { 72 | debug!("D-Bus Client: Trying to get server information"); 73 | let reply = self.proxy.get_server_information().await?; 74 | 75 | debug!("D-Bus Client: Receieved server information"); 76 | Ok(reply) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /crates/shared/src/cached_data.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use log::error; 4 | 5 | use crate::file_watcher::FileState; 6 | 7 | pub struct CachedData(HashMap) 8 | where 9 | K: std::cmp::Eq + std::hash::Hash, 10 | V: for<'a> TryFrom<&'a K, Error = CachedValueError>; 11 | 12 | impl CachedData 13 | where 14 | K: std::cmp::Eq + std::hash::Hash + ToOwned, 15 | V: for<'a> TryFrom<&'a K, Error = CachedValueError>, 16 | { 17 | pub fn new() -> Self { 18 | CachedData(HashMap::new()) 19 | } 20 | 21 | pub fn get(&self, key: &K) -> Option<&V> { 22 | self.0.get(key) 23 | } 24 | 25 | pub fn update(&mut self) -> bool 26 | where 27 | V: CacheUpdate, 28 | { 29 | let mut updated = false; 30 | self.0 31 | .values_mut() 32 | .for_each(|value| match value.check_updates() { 33 | FileState::Updated => { 34 | value.update(); 35 | updated = true 36 | } 37 | FileState::NothingChanged | FileState::NotFound => (), 38 | }); 39 | updated 40 | } 41 | 42 | pub fn extend_by_keys(&mut self, keys: Vec) { 43 | self.0.retain(|key, _| keys.contains(key)); 44 | 45 | for key in keys { 46 | if self.0.contains_key(&key) { 47 | continue; 48 | } 49 | 50 | match V::try_from(&key) { 51 | Ok(data) => { 52 | self.0.insert(key, data); 53 | } 54 | Err(err) => { 55 | error!("{err}") 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | impl<'a, K, V> FromIterator<&'a K> for CachedData 63 | where 64 | K: std::cmp::Eq + std::hash::Hash + ToOwned, 65 | V: for<'b> TryFrom<&'b K, Error = CachedValueError>, 66 | { 67 | fn from_iter>(iter: T) -> Self { 68 | let data = iter 69 | .into_iter() 70 | .filter_map(|key| V::try_from(key).map(|value| (key, value)).ok()) 71 | .fold(HashMap::new(), |mut acc, (key, value)| { 72 | acc.insert(key.to_owned(), value); 73 | acc 74 | }); 75 | 76 | Self(data) 77 | } 78 | } 79 | 80 | impl Default for CachedData 81 | where 82 | K: std::cmp::Eq + std::hash::Hash + ToOwned, 83 | V: for<'a> TryFrom<&'a K, Error = CachedValueError>, 84 | { 85 | fn default() -> Self { 86 | Self::new() 87 | } 88 | } 89 | 90 | pub trait CacheUpdate { 91 | fn check_updates(&mut self) -> FileState; 92 | fn update(&mut self); 93 | } 94 | 95 | #[derive(derive_more::Display)] 96 | pub enum CachedValueError { 97 | #[display("Failed to init file watcher for file. Error: {source}")] 98 | FailedInitWatcher { source: anyhow::Error }, 99 | } 100 | -------------------------------------------------------------------------------- /crates/dbus/src/image.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, io::Write}; 2 | 3 | use shared::file_descriptor::FileDescriptor; 4 | use zbus::zvariant::{Array, Structure, Value}; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct ImageData { 8 | /// Width of image in pixels 9 | pub width: i32, 10 | 11 | /// Height of image in pixels 12 | pub height: i32, 13 | 14 | /// Distance in bytes between row starts 15 | pub rowstride: i32, 16 | 17 | /// Whether the image has an alpha channel 18 | pub has_alpha: bool, 19 | 20 | /// Must always be 8 21 | /// 22 | /// It's because of specification. 23 | pub bits_per_sample: i32, 24 | 25 | /// If has_alpha is **true**, must be 4, otherwise 3 26 | pub channels: i32, 27 | 28 | /// The image data, in RGB byte order 29 | /// 30 | /// To avoid the stroing data in RAM, the image stores in temporary file which will be 31 | /// destroyed if there is no handle to this file. 32 | pub image_file_descriptor: FileDescriptor, 33 | } 34 | 35 | impl ImageData { 36 | pub fn from_hint(hint: Value<'_>) -> Option { 37 | Structure::try_from(hint) 38 | .ok() 39 | .and_then(Self::from_structure) 40 | } 41 | 42 | fn from_structure(image_structure: Structure) -> Option { 43 | fn get_field<'a, 'b>( 44 | fields: &'a mut HashMap>, 45 | index: &'a usize, 46 | ) -> Value<'b> { 47 | unsafe { fields.remove(index).unwrap_unchecked() } 48 | } 49 | 50 | let mut fields = image_structure.into_fields().into_iter().enumerate().fold( 51 | HashMap::new(), 52 | |mut acc, (index, value)| { 53 | acc.insert(index, value); 54 | acc 55 | }, 56 | ); 57 | 58 | if fields.len() < 7 { 59 | return None; 60 | } 61 | 62 | let width = i32::try_from(get_field(&mut fields, &0)).ok()?; 63 | let height = i32::try_from(get_field(&mut fields, &1)).ok()?; 64 | let rowstride = i32::try_from(get_field(&mut fields, &2)).ok()?; 65 | let has_alpha = bool::try_from(get_field(&mut fields, &3)).ok()?; 66 | let bits_per_sample = i32::try_from(get_field(&mut fields, &4)).ok()?; 67 | let channels = i32::try_from(get_field(&mut fields, &5)).ok()?; 68 | 69 | let file = match Array::try_from(get_field(&mut fields, &6)) { 70 | Ok(array) => { 71 | const BUF_SIZE_4KB: usize = 4096; 72 | let mut file = tempfile::tempfile().expect("The temp file must be created!"); 73 | array 74 | .chunks(BUF_SIZE_4KB) 75 | .map(|bytes| { 76 | bytes 77 | .iter() 78 | .map(|byte| u8::try_from(byte).expect("Expected u8 byte of image data")) 79 | .collect::>() 80 | }) 81 | .for_each(|buffer| { 82 | file.write_all(&buffer) 83 | .expect("The temp file must be able to write image.") 84 | }); 85 | 86 | file 87 | } 88 | Err(_) => return None, 89 | }; 90 | 91 | Some(ImageData { 92 | width, 93 | height, 94 | rowstride, 95 | has_alpha, 96 | bits_per_sample, 97 | channels, 98 | image_file_descriptor: file.into(), 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /crates/macros/tests/config_property.rs: -------------------------------------------------------------------------------- 1 | #[derive(macros::ConfigProperty, PartialEq, Debug)] 2 | #[cfg_prop(name(TomlConfig), derive(Default))] 3 | struct Config { 4 | value: i32, 5 | value2: String, 6 | 7 | #[cfg_prop(use_type(TomlNestedConfig), also_from(name = value1, mergeable))] 8 | value3: NestedConfig, 9 | 10 | #[cfg_prop(use_type(TomlNestedConfig), also_from(name = value1, mergeable))] 11 | value4: NestedConfig, 12 | } 13 | 14 | #[derive(macros::ConfigProperty, PartialEq, Debug)] 15 | #[cfg_prop(name(TomlNestedConfig), derive(Clone, Default))] 16 | struct NestedConfig { 17 | #[cfg_prop(also_from(name = value3))] 18 | value: f32, 19 | 20 | value1: bool, 21 | 22 | #[cfg_prop(also_from(name = value3))] 23 | value2: f32, 24 | } 25 | 26 | #[test] 27 | fn simple_test() { 28 | let nested_config = TomlNestedConfig { 29 | value: None, 30 | value1: None, 31 | value2: Some(0.0), 32 | value3: Some(1.0), 33 | }; 34 | 35 | assert_eq!( 36 | nested_config.unwrap_or_default(), 37 | NestedConfig { 38 | value: 1.0, 39 | value1: false, 40 | value2: 0.0 41 | } 42 | ) 43 | } 44 | 45 | #[test] 46 | fn use_defaults() { 47 | #[derive(macros::ConfigProperty, PartialEq, Debug)] 48 | #[cfg_prop(name(TomlSample), derive(Default))] 49 | struct Sample { 50 | #[cfg_prop(default)] 51 | value: i32, 52 | #[cfg_prop(default(true))] 53 | value2: bool, 54 | #[cfg_prop(default(path = Sample::default_string))] 55 | value3: String, 56 | } 57 | 58 | impl Sample { 59 | fn default_string() -> String { 60 | "Hell".to_string() 61 | } 62 | } 63 | 64 | let sample = TomlSample::default().unwrap_or_default(); 65 | assert_eq!( 66 | sample, 67 | Sample { 68 | value: 0, 69 | value2: true, 70 | value3: "Hell".to_string() 71 | } 72 | ) 73 | } 74 | 75 | #[test] 76 | fn temporary_fields() { 77 | #[derive(macros::ConfigProperty, PartialEq, Debug)] 78 | #[cfg_prop(name(TomlSample), derive(Default))] 79 | struct Sample { 80 | #[cfg_prop(also_from(name = value5))] 81 | value: i32, 82 | #[cfg_prop(also_from(name = value5))] 83 | value1: i32, 84 | value2: f32, 85 | } 86 | 87 | let sample = TomlSample { 88 | value5: Some(30), 89 | ..Default::default() 90 | } 91 | .unwrap_or_default(); 92 | 93 | assert_eq!( 94 | sample, 95 | Sample { 96 | value: 30, 97 | value1: 30, 98 | value2: 0.0 99 | } 100 | ) 101 | } 102 | 103 | #[test] 104 | fn complex_test() { 105 | let nested = TomlNestedConfig { 106 | value3: Some(1.0), 107 | 108 | ..Default::default() 109 | }; 110 | 111 | let second_nested = TomlNestedConfig { 112 | value1: Some(true), 113 | ..Default::default() 114 | }; 115 | 116 | let config = TomlConfig { 117 | value: Some(30), 118 | value1: Some(nested.clone()), 119 | value4: Some(second_nested), 120 | ..Default::default() 121 | } 122 | .unwrap_or_default(); 123 | 124 | assert_eq!( 125 | config, 126 | Config { 127 | value: 30, 128 | value2: "".to_string(), 129 | value3: nested.unwrap_or_default(), 130 | value4: NestedConfig { 131 | value: 1.0, 132 | value1: true, 133 | value2: 1.0 134 | } 135 | } 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /crates/shared/src/value.rs: -------------------------------------------------------------------------------- 1 | use crate::error::ConversionError; 2 | 3 | #[derive(Debug)] 4 | pub enum Value { 5 | UInt(usize), 6 | String(String), 7 | Any(Box), 8 | } 9 | 10 | pub trait TryFromValue: Sized + 'static { 11 | fn try_from_cloned(value: &Value) -> Result 12 | where 13 | Self: Clone, 14 | { 15 | match value { 16 | Value::String(string) => Self::try_from_string(string.clone()), 17 | Value::UInt(uint) => Self::try_from_uint(*uint), 18 | Value::Any(dyn_value) => dyn_value.try_downcast_ref().cloned(), 19 | } 20 | } 21 | 22 | fn try_from(value: Value) -> Result { 23 | match value { 24 | Value::String(string) => Self::try_from_string(string), 25 | Value::UInt(uint) => Self::try_from_uint(uint), 26 | Value::Any(dyn_value) => dyn_value.try_downcast(), 27 | } 28 | } 29 | 30 | fn try_from_string(_value: String) -> Result { 31 | Err(ConversionError::CannotConvert) 32 | } 33 | 34 | fn try_from_uint(_value: usize) -> Result { 35 | Err(ConversionError::CannotConvert) 36 | } 37 | } 38 | 39 | pub trait TryDowncast: Sized { 40 | fn try_downcast(self) -> Result; 41 | fn try_downcast_ref(&self) -> Result<&T, ConversionError>; 42 | } 43 | 44 | impl TryDowncast for Box { 45 | fn try_downcast(self) -> Result { 46 | Ok(*self 47 | .downcast() 48 | .map_err(|_| ConversionError::AnyDoesntMatchType { 49 | concrete_typename: std::any::type_name::(), 50 | })?) 51 | } 52 | 53 | fn try_downcast_ref(&self) -> Result<&T, ConversionError> { 54 | self.downcast_ref() 55 | .ok_or_else(|| ConversionError::AnyDoesntMatchType { 56 | concrete_typename: std::any::type_name::(), 57 | }) 58 | } 59 | } 60 | 61 | macro_rules! impl_from_for_value { 62 | ($type:ty => $variant:path) => { 63 | impl From<$type> for Value { 64 | fn from(value: $type) -> Self { 65 | $variant(value) 66 | } 67 | } 68 | }; 69 | } 70 | 71 | impl_from_for_value!(usize => Value::UInt); 72 | impl_from_for_value!(String => Value::String); 73 | impl_from_for_value!(Box => Value::Any); 74 | 75 | impl TryFromValue for usize { 76 | fn try_from_uint(value: usize) -> Result { 77 | Ok(value) 78 | } 79 | } 80 | 81 | impl TryFromValue for String { 82 | fn try_from_string(value: String) -> Result { 83 | Ok(value) 84 | } 85 | } 86 | 87 | impl TryFromValue for Vec {} 88 | 89 | macro_rules! impl_try_from_value_for_uint { 90 | ($($type:path),*) => { 91 | $( 92 | impl TryFromValue for $type { 93 | fn try_from_uint(value: usize) -> Result { 94 | Ok(value.clamp(<$type>::MIN as usize, <$type>::MAX as usize) as $type) 95 | } 96 | } 97 | )* 98 | }; 99 | } 100 | 101 | impl_try_from_value_for_uint!(u8, u16, u32, i8, i16, i32); 102 | 103 | impl TryFromValue for bool { 104 | fn try_from_string(value: String) -> Result { 105 | value 106 | .parse::() 107 | .map_err(|_| ConversionError::InvalidValue { 108 | expected: "true or false", 109 | actual: value, 110 | }) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /crates/backend/src/managers/idle_manager/idle_notifier.rs: -------------------------------------------------------------------------------- 1 | use config::Config; 2 | use log::debug; 3 | use wayland_client::{protocol::wl_seat::WlSeat, Dispatch, QueueHandle}; 4 | use wayland_protocols::ext::idle_notify::v1::client::{ 5 | ext_idle_notification_v1::{self, ExtIdleNotificationV1}, 6 | ext_idle_notifier_v1::ExtIdleNotifierV1, 7 | }; 8 | 9 | pub struct IdleNotifier { 10 | notification: Option, 11 | 12 | threshold: u32, 13 | pub idle_state: Option, 14 | pub was_idled: bool, 15 | } 16 | 17 | pub enum IdleState { 18 | Idled, 19 | Resumed, 20 | } 21 | 22 | impl IdleNotifier { 23 | pub(crate) fn init

( 24 | protocotls: &P, 25 | qhandle: &QueueHandle, 26 | config: &Config, 27 | ) -> anyhow::Result 28 | where 29 | P: AsRef + AsRef, 30 | { 31 | let threshold = config.general().idle_threshold.duration; 32 | 33 | let notification = if threshold != 0 { 34 | Some( 35 |

>::as_ref(protocotls).get_idle_notification( 36 | threshold, 37 |

>::as_ref(protocotls), 38 | qhandle, 39 | (), 40 | ), 41 | ) 42 | } else { 43 | None 44 | }; 45 | 46 | let idle_notifier = Self { 47 | notification, 48 | 49 | idle_state: None, 50 | threshold, 51 | was_idled: false, 52 | }; 53 | 54 | debug!("Idle Notifier: Initialized"); 55 | Ok(idle_notifier) 56 | } 57 | 58 | pub(super) fn recreate

( 59 | &mut self, 60 | protocotls: &P, 61 | qhandle: &QueueHandle, 62 | config: &Config, 63 | ) where 64 | P: AsRef + AsRef, 65 | { 66 | self.threshold = config.general().idle_threshold.duration; 67 | if let Some(notification) = self.notification.take() { 68 | self.idle_state = None; 69 | self.was_idled = false; 70 | notification.destroy(); 71 | debug!("Idle Notifier: Destroyed") 72 | } 73 | 74 | if self.threshold != 0 { 75 | self.notification.replace( 76 |

>::as_ref(protocotls).get_idle_notification( 77 | self.threshold, 78 |

>::as_ref(protocotls), 79 | qhandle, 80 | (), 81 | ), 82 | ); 83 | debug!("Idle Notifier: Recreated by new idle_threshold value"); 84 | } 85 | } 86 | } 87 | 88 | impl Dispatch for IdleNotifier { 89 | fn event( 90 | state: &mut Self, 91 | _idle_notification: &ext_idle_notification_v1::ExtIdleNotificationV1, 92 | event: ::Event, 93 | _data: &(), 94 | _conn: &wayland_client::Connection, 95 | _qhandle: &wayland_client::QueueHandle, 96 | ) { 97 | match event { 98 | ext_idle_notification_v1::Event::Idled => { 99 | state.idle_state = Some(IdleState::Idled); 100 | state.was_idled = true; 101 | debug!("Idle Notifier: Idled"); 102 | } 103 | ext_idle_notification_v1::Event::Resumed => { 104 | state.idle_state = Some(IdleState::Resumed); 105 | debug!("Idle Notifier: Resumed"); 106 | } 107 | _ => (), 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /crates/filetype/src/parser.rs: -------------------------------------------------------------------------------- 1 | use pest::{iterators::Pairs, Parser}; 2 | use pest_derive::Parser; 3 | 4 | #[derive(Parser)] 5 | #[grammar = "layout.pest"] 6 | pub(super) struct LayoutParser; 7 | 8 | pub(super) fn parse(input: &str) -> anyhow::Result> { 9 | Ok(LayoutParser::parse(Rule::Layout, input)?) 10 | } 11 | 12 | #[test] 13 | fn minimal_example() { 14 | LayoutParser::parse( 15 | Rule::Layout, 16 | r#" 17 | FlexContainer( 18 | max_width = larst, 19 | max_height = 4, 20 | property = Property( 21 | config_val = true, 22 | ), 23 | ) { 24 | Image() 25 | Summary(kind = summary) 26 | } 27 | "#, 28 | ) 29 | .unwrap(); 30 | } 31 | 32 | #[test] 33 | #[should_panic] 34 | fn missing_closing_parenthesis() { 35 | LayoutParser::parse( 36 | Rule::Layout, 37 | r#" 38 | FlexContainer( {} 39 | "#, 40 | ) 41 | .unwrap(); 42 | } 43 | 44 | #[test] 45 | #[should_panic] 46 | fn missing_comma_in_properties() { 47 | LayoutParser::parse( 48 | Rule::Layout, 49 | r#" 50 | FlexContainer( 51 | min_width = 3 52 | max_width = 4 53 | ) 54 | "#, 55 | ) 56 | .unwrap(); 57 | } 58 | 59 | #[test] 60 | #[should_panic] 61 | fn redundant_comma_in_children() { 62 | LayoutParser::parse( 63 | Rule::Layout, 64 | r#" 65 | FlexContainer( 66 | min_width = 3, 67 | max_width = 4 68 | ) { 69 | Text(), 70 | Image() 71 | } 72 | "#, 73 | ) 74 | .unwrap(); 75 | } 76 | 77 | #[test] 78 | #[should_panic] 79 | fn test_redundant_semicolon_in_children() { 80 | LayoutParser::parse( 81 | Rule::Layout, 82 | r#" 83 | FlexContainer( 84 | min_width = 3, 85 | max_width = 4 86 | ) { 87 | Text(); 88 | Image(); 89 | } 90 | "#, 91 | ) 92 | .unwrap(); 93 | } 94 | 95 | #[test] 96 | #[should_panic] 97 | fn test_invalid_alias_definition() { 98 | LayoutParser::parse( 99 | Rule::Layout, 100 | r#" 101 | alas Test = Summary() 102 | 103 | FlexContainer( 104 | min_width = 3, 105 | max_width = 4 106 | ) { 107 | Text() 108 | Image() 109 | } 110 | "#, 111 | ) 112 | .unwrap(); 113 | } 114 | 115 | #[test] 116 | #[should_panic] 117 | fn test_invalid_alias_definition2() { 118 | LayoutParser::parse( 119 | Rule::Layout, 120 | r#" 121 | alias Test = 3 122 | 123 | FlexContainer( 124 | min_width = 3, 125 | max_width = 4 126 | ) { 127 | Text() 128 | Image() 129 | } 130 | "#, 131 | ) 132 | .unwrap(); 133 | } 134 | 135 | #[test] 136 | #[should_panic] 137 | fn test_invalid_alias_definition3() { 138 | LayoutParser::parse( 139 | Rule::Layout, 140 | r#" 141 | alias Test = literal 142 | 143 | FlexContainer( 144 | min_width = 3, 145 | max_width = 4 146 | ) { 147 | Text() 148 | Image() 149 | } 150 | "#, 151 | ) 152 | .unwrap(); 153 | } 154 | 155 | #[test] 156 | #[should_panic] 157 | fn test_invalid_alias_definition4() { 158 | LayoutParser::parse( 159 | Rule::Layout, 160 | r#" 161 | alias _ = Text() 162 | 163 | FlexContainer( 164 | min_width = 3, 165 | max_width = 4 166 | ) { 167 | Text() 168 | Image() 169 | } 170 | "#, 171 | ) 172 | .unwrap(); 173 | } 174 | -------------------------------------------------------------------------------- /crates/backend/src/scheduler.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Local, NaiveDateTime, NaiveTime, TimeZone, Utc}; 2 | use dbus::notification::ScheduledNotification; 3 | use log::{debug, warn}; 4 | use std::cmp::Reverse; 5 | use std::collections::BinaryHeap; 6 | 7 | pub struct Scheduler { 8 | queue: BinaryHeap>, 9 | } 10 | 11 | impl Scheduler { 12 | pub fn new() -> Self { 13 | Scheduler { 14 | queue: BinaryHeap::new(), 15 | } 16 | } 17 | 18 | pub fn add(&mut self, notification: ScheduledNotification) { 19 | match Self::parse_time(¬ification.time) { 20 | Ok(parsed_time) => { 21 | debug!( 22 | "Successfully parsed time '{}' for scheduling: {}", 23 | ¬ification.time, &parsed_time 24 | ); 25 | 26 | let scheduled_notification = ScheduledNotification { 27 | time: parsed_time.to_rfc3339(), 28 | data: notification.data, 29 | id: notification.id, 30 | }; 31 | self.queue.push(Reverse(scheduled_notification)); 32 | } 33 | Err(e) => { 34 | warn!( 35 | "Failed to parse time '{}' for notification with id '{}': {}", 36 | ¬ification.time, ¬ification.data.id, e 37 | ); 38 | } 39 | } 40 | } 41 | 42 | pub fn pop_due_notifications(&mut self) -> Vec { 43 | let now = Utc::now(); 44 | let mut due_notifications = Vec::new(); 45 | 46 | while let Some(Reverse(top)) = self.queue.peek() { 47 | match top.time.parse::>() { 48 | Ok(time) if time <= now => { 49 | due_notifications.push(self.queue.pop().unwrap().0); 50 | } 51 | _ => break, 52 | } 53 | } 54 | 55 | due_notifications 56 | } 57 | 58 | fn parse_time(time_str: &str) -> Result, chrono::ParseError> { 59 | let now = Utc::now(); 60 | 61 | if let Ok(duration) = humantime::parse_duration(time_str) { 62 | return Ok(now + chrono::Duration::from_std(duration).unwrap()); 63 | } 64 | 65 | const DATETIME_FORMATS: &[&str] = &[ 66 | "%Y.%m.%d %H:%M", // 2025.01.01 00:00 67 | "%Y-%m-%d %H:%M", // 2025-01-01 00:00 68 | // 69 | "%d.%m.%Y %H:%M", // 01.01.2025 00:00 70 | "%d-%m-%Y %H:%M", // 01-01-2025 00:00 71 | // 72 | "%d.%m.%Y %I:%M %p", // 01.01.2025 12:00 AM 73 | "%d-%m-%Y %I:%M %p", // 01-01-2025 12:00 AM 74 | // 75 | "%Y.%m.%d %I:%M %p", // 2025.01.01 12:00 AM 76 | "%Y-%m-%d %I:%M %p", // 2025-01-01 12:00 AM 77 | ]; 78 | 79 | const TIME_FORMATS: &[&str] = &[ 80 | "%H:%M", // 18:45 81 | "%I:%M %p", // 06:45 PM 82 | ]; 83 | 84 | if let Some(datetime) = DATETIME_FORMATS.iter().find_map(|&format| { 85 | NaiveDateTime::parse_from_str(time_str, format) 86 | .ok() 87 | .map(|parsed| Self::from_local_to_utc(&parsed)) 88 | }) { 89 | return Ok(datetime); 90 | } 91 | 92 | if let Some(datetime) = TIME_FORMATS.iter().find_map(|&format| { 93 | NaiveTime::parse_from_str(time_str, format) 94 | .ok() 95 | .map(|parsed| { 96 | let today = Local::now().date_naive(); 97 | Self::from_local_to_utc(&today.and_time(parsed)) 98 | }) 99 | }) { 100 | return Ok(datetime); 101 | } 102 | 103 | time_str.parse::>() 104 | } 105 | 106 | fn from_local_to_utc(datetime: &NaiveDateTime) -> DateTime { 107 | Local 108 | .from_local_datetime(datetime) 109 | .single() 110 | .expect("Local time conversion failed: ambiguous or invalid local time") 111 | .with_timezone(&Utc) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /crates/config/src/general.rs: -------------------------------------------------------------------------------- 1 | //! The module that contain the structure `GeneralConfig` which stores general config properties. 2 | //! 3 | //! With it the module also stores `TomlGeneralConfig` which can parse data from TOML data. 4 | 5 | use macros::ConfigProperty; 6 | use serde::Deserialize; 7 | 8 | use crate::{public, sorting::Sorting}; 9 | 10 | public! { 11 | #[derive(ConfigProperty, Debug)] 12 | #[cfg_prop(name(TomlGeneralConfig), derive(Debug, Default, Deserialize, Clone))] 13 | struct GeneralConfig { 14 | font: Font, 15 | 16 | #[cfg_prop(default(300))] 17 | width: u16, 18 | #[cfg_prop(default(150))] 19 | height: u16, 20 | 21 | anchor: Anchor, 22 | offset: (u8, u8), 23 | #[cfg_prop(default(10))] 24 | gap: u8, 25 | 26 | sorting: Sorting, 27 | 28 | #[cfg_prop(default(0))] 29 | limit: u8, 30 | 31 | idle_threshold: IdleThreshold, 32 | } 33 | } 34 | 35 | public! { 36 | #[derive(Debug, Deserialize, Clone)] 37 | #[serde(from = "String")] 38 | struct IdleThreshold { 39 | duration: u32, 40 | } 41 | } 42 | 43 | impl From for IdleThreshold { 44 | fn from(duration_str: String) -> Self { 45 | if duration_str.to_lowercase() == "none" { 46 | return Self { duration: 0 }; 47 | } 48 | 49 | humantime::parse_duration(&duration_str) 50 | .map(|duration| Self { 51 | duration: duration.as_millis() as u32, 52 | }) 53 | .unwrap_or_default() 54 | } 55 | } 56 | 57 | impl Default for IdleThreshold { 58 | fn default() -> Self { 59 | IdleThreshold { 60 | duration: humantime::parse_duration("5 min") 61 | .expect("The default duration must be valid") 62 | .as_millis() as u32, 63 | } 64 | } 65 | } 66 | 67 | public! { 68 | #[derive(Debug, Deserialize, Clone)] 69 | #[serde(from = "String")] 70 | struct Font { 71 | name: String, 72 | } 73 | } 74 | 75 | impl From for Font { 76 | fn from(name: String) -> Self { 77 | Font { name } 78 | } 79 | } 80 | 81 | impl Default for Font { 82 | fn default() -> Self { 83 | Font { 84 | name: "Noto Sans".to_string(), 85 | } 86 | } 87 | } 88 | 89 | #[derive(Debug, Deserialize, Default, Clone)] 90 | #[serde(from = "String")] 91 | pub enum Anchor { 92 | Top, 93 | TopLeft, 94 | #[default] 95 | TopRight, 96 | Bottom, 97 | BottomLeft, 98 | BottomRight, 99 | Left, 100 | Right, 101 | } 102 | 103 | impl Anchor { 104 | pub fn is_top(&self) -> bool { 105 | matches!(self, Anchor::Top | Anchor::TopLeft | Anchor::TopRight) 106 | } 107 | 108 | pub fn is_right(&self) -> bool { 109 | matches!(self, Anchor::TopRight | Anchor::BottomRight | Anchor::Right) 110 | } 111 | 112 | pub fn is_bottom(&self) -> bool { 113 | matches!( 114 | self, 115 | Anchor::Bottom | Anchor::BottomLeft | Anchor::BottomRight 116 | ) 117 | } 118 | 119 | pub fn is_left(&self) -> bool { 120 | matches!(self, Anchor::TopLeft | Anchor::BottomLeft | Anchor::Left) 121 | } 122 | } 123 | 124 | impl From for Anchor { 125 | fn from(value: String) -> Self { 126 | match value.as_str() { 127 | "top" => Anchor::Top, 128 | "top-left" | "top left" => Anchor::TopLeft, 129 | "top-right" | "top right" => Anchor::TopRight, 130 | "bottom" => Anchor::Bottom, 131 | "bottom-left" | "bottom left" => Anchor::BottomLeft, 132 | "bottom-right" | "bottom right" => Anchor::BottomRight, 133 | "left" => Anchor::Left, 134 | "right" => Anchor::Right, 135 | other => panic!( 136 | "Invalid anchor option! There are possible values:\n\ 137 | - \"top\"\n\ 138 | - \"top-right\" or \"top right\"\n\ 139 | - \"top-left\" or \"top left\"\n\ 140 | - bottom\n\ 141 | - \"bottom-right\" or \"bottom right\"\n\ 142 | - \"bottom-left\" or \"bottom left\"\n\ 143 | - left\n\ 144 | - right\n\ 145 | Used: {other}" 146 | ), 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /crates/macros/tests/generic_builder.rs: -------------------------------------------------------------------------------- 1 | use shared::value::{TryFromValue, Value}; 2 | 3 | #[derive(macros::GenericBuilder, PartialEq, Debug)] 4 | #[gbuilder(name(GBuilderHomogeneousStruct), constructor)] 5 | #[allow(unused)] 6 | struct HomogeneousStruct { 7 | field1: usize, 8 | field2: usize, 9 | field3: usize, 10 | field4: usize, 11 | field5: usize, 12 | } 13 | 14 | #[test] 15 | fn homogeneous_struct() { 16 | let mut gbuilder = GBuilderHomogeneousStruct::new(); 17 | gbuilder.constructor(Value::UInt(5)).unwrap(); 18 | let result = gbuilder.try_build().unwrap(); 19 | 20 | assert_eq!( 21 | result, 22 | HomogeneousStruct { 23 | field1: 5, 24 | field2: 5, 25 | field3: 5, 26 | field4: 5, 27 | field5: 5 28 | } 29 | ) 30 | } 31 | 32 | #[derive(macros::GenericBuilder, Eq, PartialEq, Debug)] 33 | #[gbuilder(name(GBuilderTest))] 34 | struct Test { 35 | #[gbuilder(aliases(field4))] 36 | field1: usize, 37 | #[gbuilder(aliases(field4), default)] 38 | field5: usize, 39 | field2: String, 40 | #[gbuilder(hidden, default)] 41 | field3: Option, 42 | } 43 | 44 | #[test] 45 | fn build_struct() -> Result<(), Box> { 46 | let mut gbuilder = GBuilderTest::new(); 47 | 48 | gbuilder.set_value("field4", Value::UInt(3))?; 49 | gbuilder.set_value("field2", Value::String("hell".to_string()))?; 50 | 51 | let failure_assignment = gbuilder.set_value("field3", Value::UInt(5)); 52 | assert!(failure_assignment.is_err()); 53 | assert_eq!( 54 | failure_assignment.err().unwrap().to_string(), 55 | shared::error::ConversionError::UnknownField { 56 | field_name: "field3".to_string(), 57 | value: Value::UInt(5), 58 | } 59 | .to_string() 60 | ); 61 | 62 | let result = gbuilder.try_build()?; 63 | 64 | assert_eq!( 65 | result, 66 | Test { 67 | field1: 3, 68 | field5: 3, 69 | field2: "hell".to_string(), 70 | field3: None 71 | } 72 | ); 73 | Ok(()) 74 | } 75 | 76 | #[derive(macros::GenericBuilder, Debug, Eq, PartialEq)] 77 | #[gbuilder(name(GBuilderComplexStructure))] 78 | struct ComplexStructure { 79 | field1: usize, 80 | field2: String, 81 | field3: InnerStructure, 82 | } 83 | 84 | #[derive(Debug, Eq, PartialEq, Clone)] 85 | enum InnerStructure { 86 | First, 87 | Second, 88 | } 89 | 90 | impl TryFromValue for InnerStructure { 91 | fn try_from_string(value: String) -> Result { 92 | match &*value { 93 | "first" => Ok(InnerStructure::First), 94 | "second" => Ok(InnerStructure::Second), 95 | _ => Err(shared::error::ConversionError::InvalidValue { 96 | expected: "first or second", 97 | actual: value, 98 | }), 99 | } 100 | } 101 | } 102 | 103 | #[test] 104 | fn build_complex_structure() -> Result<(), Box> { 105 | let mut gbuilder = GBuilderComplexStructure::new(); 106 | 107 | gbuilder 108 | .set_value("field1", Value::UInt(5))? 109 | .set_value("field2", Value::String("hell".to_string()))? 110 | .set_value("field3", Value::String("first".to_string()))?; 111 | 112 | let mut second_gbuilder = GBuilderComplexStructure::new(); 113 | let inner_value = Value::Any(Box::new(InnerStructure::First)); 114 | 115 | second_gbuilder 116 | .set_value("field1", Value::UInt(5))? 117 | .set_value("field2", Value::String("hell".to_string()))? 118 | .set_value("field3", inner_value)?; 119 | 120 | let first_struct = gbuilder.try_build()?; 121 | let second_struct = second_gbuilder.try_build()?; 122 | assert_eq!(first_struct, second_struct); 123 | 124 | assert_eq!( 125 | first_struct, 126 | ComplexStructure { 127 | field1: 5, 128 | field2: "hell".to_string(), 129 | field3: InnerStructure::First 130 | } 131 | ); 132 | 133 | Ok(()) 134 | } 135 | 136 | #[test] 137 | #[should_panic(expected = "The field 'field1' should be set")] 138 | fn empty_builder_should_panic() { 139 | GBuilderComplexStructure::new().try_build().unwrap(); 140 | } 141 | 142 | #[test] 143 | #[should_panic(expected = "The field 'field2' should be set")] 144 | fn not_fulled_builder_should_panic() { 145 | let mut gbuilder = GBuilderComplexStructure::new(); 146 | gbuilder.set_value("field1", Value::UInt(5)).unwrap(); 147 | gbuilder.try_build().unwrap(); 148 | } 149 | -------------------------------------------------------------------------------- /crates/render/src/types.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{Add, AddAssign, Mul}; 2 | 3 | use config::spacing::Spacing; 4 | 5 | #[derive(Debug, Default, Clone, Copy)] 6 | pub struct RectSize 7 | where 8 | T: Default + Copy, 9 | { 10 | pub width: T, 11 | pub height: T, 12 | } 13 | 14 | impl RectSize 15 | where 16 | T: Default + Copy, 17 | { 18 | pub fn new(width: T, height: T) -> Self { 19 | Self { width, height } 20 | } 21 | 22 | pub fn new_square(side: T) -> Self { 23 | Self { 24 | width: side, 25 | height: side, 26 | } 27 | } 28 | 29 | pub fn new_width(width: T) -> Self { 30 | Self { 31 | width, 32 | ..Default::default() 33 | } 34 | } 35 | 36 | pub fn new_height(height: T) -> Self { 37 | Self { 38 | height, 39 | ..Default::default() 40 | } 41 | } 42 | 43 | pub fn area(&self) -> T 44 | where 45 | T: Mul, 46 | { 47 | self.width * self.height 48 | } 49 | } 50 | 51 | impl RectSize { 52 | pub fn shrink_by(&mut self, spacing: &Spacing) { 53 | self.width = self 54 | .width 55 | .saturating_sub(spacing.left() as usize + spacing.right() as usize); 56 | self.height = self 57 | .height 58 | .saturating_sub(spacing.top() as usize + spacing.bottom() as usize); 59 | } 60 | } 61 | 62 | impl From> for RectSize { 63 | fn from(value: RectSize) -> Self { 64 | Self { 65 | width: value.width as f64, 66 | height: value.height as f64, 67 | } 68 | } 69 | } 70 | 71 | impl From> for RectSize { 72 | fn from(value: RectSize) -> Self { 73 | Self { 74 | width: value.width.round() as usize, 75 | height: value.height.round() as usize, 76 | } 77 | } 78 | } 79 | 80 | impl Add> for RectSize 81 | where 82 | T: Add + Default + Copy, 83 | { 84 | type Output = RectSize; 85 | 86 | fn add(self, rhs: RectSize) -> Self::Output { 87 | Self { 88 | width: self.width + rhs.width, 89 | height: self.height + rhs.height, 90 | } 91 | } 92 | } 93 | 94 | impl AddAssign> for RectSize 95 | where 96 | T: AddAssign + Default + Copy, 97 | { 98 | fn add_assign(&mut self, rhs: RectSize) { 99 | self.width += rhs.width; 100 | self.height += rhs.height; 101 | } 102 | } 103 | 104 | #[derive(Debug, Default, Clone, Copy)] 105 | pub struct Offset 106 | where 107 | T: Add + Default + Copy, 108 | { 109 | pub x: T, 110 | pub y: T, 111 | } 112 | 113 | impl Offset 114 | where 115 | T: Add + Default + Copy, 116 | { 117 | pub fn new(x: T, y: T) -> Self { 118 | Self { x, y } 119 | } 120 | 121 | pub fn new_x(x: T) -> Self { 122 | Self { 123 | x, 124 | ..Default::default() 125 | } 126 | } 127 | 128 | pub fn new_y(y: T) -> Self { 129 | Self { 130 | y, 131 | ..Default::default() 132 | } 133 | } 134 | 135 | pub fn no_offset() -> Self { 136 | Self::default() 137 | } 138 | } 139 | 140 | impl Add> for Offset 141 | where 142 | T: Add + Default + Copy, 143 | { 144 | type Output = Offset; 145 | 146 | fn add(self, rhs: Offset) -> Self::Output { 147 | Offset { 148 | x: self.x + rhs.x, 149 | y: self.y + rhs.y, 150 | } 151 | } 152 | } 153 | 154 | impl AddAssign> for Offset 155 | where 156 | T: Add + AddAssign + Default + Copy, 157 | { 158 | fn add_assign(&mut self, rhs: Offset) { 159 | self.x += rhs.x; 160 | self.y += rhs.y; 161 | } 162 | } 163 | 164 | impl From> for Offset { 165 | fn from(value: Offset) -> Self { 166 | Self { 167 | x: value.x as f64, 168 | y: value.y as f64, 169 | } 170 | } 171 | } 172 | 173 | impl From for Offset { 174 | fn from(value: Spacing) -> Self { 175 | Offset { 176 | x: value.left() as usize, 177 | y: value.top() as usize, 178 | } 179 | } 180 | } 181 | 182 | impl From<&Spacing> for Offset { 183 | fn from(value: &Spacing) -> Self { 184 | Offset { 185 | x: value.left() as usize, 186 | y: value.top() as usize, 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /crates/render/src/widget.rs: -------------------------------------------------------------------------------- 1 | use config::{display::DisplayConfig, theme::Theme}; 2 | use dbus::notification::Notification; 3 | use log::warn; 4 | use text::PangoContext; 5 | 6 | use crate::drawer::Drawer; 7 | 8 | use super::types::{Offset, RectSize}; 9 | 10 | mod flex_container; 11 | mod image; 12 | pub(crate) mod text; 13 | 14 | pub use flex_container::{ 15 | Alignment, Direction, FlexContainer, FlexContainerBuilder, GBuilderAlignment, 16 | GBuilderFlexContainer, Position, 17 | }; 18 | pub use image::{GBuilderWImage, WImage}; 19 | pub use text::{GBuilderWText, WText, WTextKind}; 20 | 21 | pub trait Draw { 22 | fn draw_with_offset( 23 | &self, 24 | offset: &Offset, 25 | pango_context: &PangoContext, 26 | drawer: &mut Drawer, 27 | ) -> pangocairo::cairo::Result<()>; 28 | 29 | fn draw( 30 | &self, 31 | pango_context: &PangoContext, 32 | drawer: &mut Drawer, 33 | ) -> pangocairo::cairo::Result<()> { 34 | self.draw_with_offset(&Default::default(), pango_context, drawer) 35 | } 36 | } 37 | 38 | #[derive(Clone)] 39 | pub enum Widget { 40 | Image(WImage), 41 | Text(WText), 42 | FlexContainer(FlexContainer), 43 | Unknown, 44 | } 45 | 46 | impl Widget { 47 | pub fn is_unknown(&self) -> bool { 48 | matches!(self, Widget::Unknown) 49 | } 50 | 51 | fn get_type(&self) -> &'static str { 52 | match self { 53 | Widget::Image(_) => "image", 54 | Widget::Text(_) => "text", 55 | Widget::FlexContainer(_) => "flex container", 56 | Widget::Unknown => "unknown", 57 | } 58 | } 59 | 60 | pub fn compile(&mut self, rect_size: RectSize, configuration: &WidgetConfiguration) { 61 | let state = match self { 62 | Widget::Image(image) => image.compile(rect_size, configuration), 63 | Widget::Text(text) => text.compile(rect_size, configuration), 64 | Widget::FlexContainer(container) => container.compile(rect_size, configuration), 65 | Widget::Unknown => CompileState::Success, 66 | }; 67 | 68 | if let CompileState::Failure = state { 69 | warn!( 70 | "A {wtype} widget is not compiled due errors!", 71 | wtype = self.get_type() 72 | ); 73 | *self = Widget::Unknown; 74 | } 75 | } 76 | 77 | pub fn len_by_direction(&self, direction: &Direction) -> usize { 78 | match direction { 79 | Direction::Horizontal => self.width(), 80 | Direction::Vertical => self.height(), 81 | } 82 | } 83 | 84 | pub fn width(&self) -> usize { 85 | match self { 86 | Widget::Image(image) => image.width(), 87 | Widget::Text(text) => text.width(), 88 | Widget::FlexContainer(container) => container.max_width(), 89 | Widget::Unknown => 0, 90 | } 91 | } 92 | 93 | pub fn height(&self) -> usize { 94 | match self { 95 | Widget::Image(image) => image.height(), 96 | Widget::Text(text) => text.height(), 97 | Widget::FlexContainer(container) => container.max_height(), 98 | Widget::Unknown => 0, 99 | } 100 | } 101 | } 102 | 103 | impl Draw for Widget { 104 | fn draw_with_offset( 105 | &self, 106 | offset: &Offset, 107 | pango_context: &PangoContext, 108 | output: &mut Drawer, 109 | ) -> pangocairo::cairo::Result<()> { 110 | match self { 111 | Widget::Image(image) => image.draw_with_offset(offset, pango_context, output), 112 | Widget::Text(text) => text.draw_with_offset(offset, pango_context, output), 113 | Widget::FlexContainer(container) => { 114 | container.draw_with_offset(offset, pango_context, output) 115 | } 116 | Widget::Unknown => Ok(()), 117 | } 118 | } 119 | } 120 | 121 | pub enum CompileState { 122 | Success, 123 | Failure, 124 | } 125 | 126 | pub struct WidgetConfiguration<'a> { 127 | pub notification: &'a Notification, 128 | pub pango_context: &'a PangoContext, 129 | pub theme: &'a Theme, 130 | pub display_config: &'a DisplayConfig, 131 | pub override_properties: bool, 132 | } 133 | 134 | impl From for Widget { 135 | fn from(value: WImage) -> Self { 136 | Widget::Image(value) 137 | } 138 | } 139 | 140 | impl From for Widget { 141 | fn from(value: WText) -> Self { 142 | Widget::Text(value) 143 | } 144 | } 145 | 146 | impl From for Widget { 147 | fn from(value: FlexContainer) -> Self { 148 | Widget::FlexContainer(value) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /crates/config/src/color.rs: -------------------------------------------------------------------------------- 1 | use std::{slice::ChunksExact, str::Chars}; 2 | 3 | use anyhow::Context; 4 | use serde::Deserialize; 5 | use shared::value::TryFromValue; 6 | 7 | use super::public; 8 | 9 | #[derive(Debug, Clone, Deserialize)] 10 | #[serde(tag = "mode", rename_all = "kebab-case")] 11 | pub enum Color { 12 | LinearGradient(LinearGradient), 13 | #[serde(untagged)] 14 | Rgba(Rgba), 15 | } 16 | 17 | impl Color { 18 | pub(super) fn new_rgba_white() -> Self { 19 | Color::Rgba(Rgba::new_white()) 20 | } 21 | 22 | pub(super) fn new_rgba_black() -> Self { 23 | Color::Rgba(Rgba::new_black()) 24 | } 25 | 26 | pub(super) fn new_rgba_red() -> Self { 27 | Color::Rgba(Rgba::new_red()) 28 | } 29 | } 30 | 31 | impl From for Color { 32 | fn from(value: Rgba) -> Self { 33 | Color::Rgba(value) 34 | } 35 | } 36 | 37 | impl From for Color { 38 | fn from(value: LinearGradient) -> Self { 39 | Color::LinearGradient(value) 40 | } 41 | } 42 | 43 | public! { 44 | #[derive(Debug, Clone, Deserialize, Default)] 45 | #[serde(try_from = "String")] 46 | struct Rgba { 47 | red: u8, 48 | green: u8, 49 | blue: u8, 50 | alpha: u8, 51 | } 52 | } 53 | 54 | impl Rgba { 55 | pub(super) fn new_white() -> Self { 56 | Self { 57 | red: 255, 58 | green: 255, 59 | blue: 255, 60 | alpha: 255, 61 | } 62 | } 63 | 64 | #[allow(unused)] 65 | pub(super) fn new_black() -> Self { 66 | Self { 67 | red: 0, 68 | green: 0, 69 | blue: 0, 70 | alpha: 255, 71 | } 72 | } 73 | 74 | pub(super) fn new_red() -> Self { 75 | Self { 76 | red: 255, 77 | green: 0, 78 | blue: 0, 79 | alpha: 255, 80 | } 81 | } 82 | 83 | fn pre_mul_alpha(self) -> Self { 84 | if self.alpha == 255 { 85 | return self; 86 | } 87 | 88 | let alpha = self.alpha as f32 / 255.0; 89 | Self { 90 | red: (self.red as f32 * alpha) as u8, 91 | green: (self.green as f32 * alpha) as u8, 92 | blue: (self.blue as f32 * alpha) as u8, 93 | alpha: self.alpha, 94 | } 95 | } 96 | } 97 | 98 | impl TryFrom for Rgba { 99 | type Error = anyhow::Error; 100 | 101 | fn try_from(value: String) -> Result { 102 | const BASE: u32 = 16; 103 | 104 | if value.len() == 4 { 105 | let mut chars = value.chars(); 106 | chars.next(); // Skip the hashtag 107 | let next_digit = |chars: &mut Chars| -> Option { 108 | let digit = chars.next()?.to_digit(BASE)? as u8; 109 | Some(digit * BASE as u8 + digit) 110 | }; 111 | 112 | const ERR_MSG: &str = "Expected valid HEX digit"; 113 | Ok(Rgba { 114 | red: next_digit(&mut chars).with_context(|| ERR_MSG)?, 115 | green: next_digit(&mut chars).with_context(|| ERR_MSG)?, 116 | blue: next_digit(&mut chars).with_context(|| ERR_MSG)?, 117 | alpha: 255, 118 | }) 119 | } else { 120 | let mut data = value.as_bytes()[1..].chunks_exact(2); 121 | 122 | fn next_slice<'a>(data: &'a mut ChunksExact) -> Result<&'a str, anyhow::Error> { 123 | data.next() 124 | .with_context(|| "Expected valid pair of HEX digits") 125 | .and_then(|slice| { 126 | std::str::from_utf8(slice).with_context(|| "Failed to parse color value") 127 | }) 128 | } 129 | 130 | Ok(Rgba { 131 | red: u8::from_str_radix(next_slice(&mut data)?, BASE)?, 132 | green: u8::from_str_radix(next_slice(&mut data)?, BASE)?, 133 | blue: u8::from_str_radix(next_slice(&mut data)?, BASE)?, 134 | alpha: if value[1..].len() == 8 { 135 | u8::from_str_radix(next_slice(&mut data)?, BASE)? 136 | } else { 137 | 255 138 | }, 139 | } 140 | .pre_mul_alpha()) 141 | } 142 | } 143 | } 144 | 145 | impl TryFromValue for Rgba { 146 | fn try_from_string(value: String) -> Result { 147 | value 148 | .clone() 149 | .try_into() 150 | .map_err(|_| shared::error::ConversionError::InvalidValue { 151 | expected: "#RGB, #RRGGBB or #RRGGBBAA", 152 | actual: value, 153 | }) 154 | } 155 | } 156 | 157 | public! { 158 | #[derive(Debug, Clone, Deserialize)] 159 | struct LinearGradient { 160 | degree: i16, 161 | colors: Vec, 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /crates/config/src/text.rs: -------------------------------------------------------------------------------- 1 | use macros::{ConfigProperty, GenericBuilder}; 2 | use serde::Deserialize; 3 | use shared::value::TryFromValue; 4 | 5 | use super::{public, Spacing}; 6 | 7 | public! { 8 | #[derive(ConfigProperty, GenericBuilder, Debug, Clone)] 9 | #[cfg_prop(name(TomlTextProperty), derive(Debug, Clone, Default, Deserialize))] 10 | #[gbuilder(name(GBuilderTextProperty), derive(Clone))] 11 | struct TextProperty { 12 | #[cfg_prop(default(true))] 13 | #[gbuilder(default(true))] 14 | wrap: bool, 15 | 16 | #[gbuilder(default)] 17 | wrap_mode: WrapMode, 18 | 19 | #[gbuilder(default)] 20 | ellipsize: Ellipsize, 21 | 22 | #[gbuilder(default)] 23 | style: TextStyle, 24 | 25 | #[gbuilder(default)] 26 | margin: Spacing, 27 | 28 | #[gbuilder(default)] 29 | alignment: TextAlignment, 30 | 31 | #[gbuilder(default(false))] 32 | justify: bool, 33 | 34 | #[cfg_prop(default(12))] 35 | font_size: u8, 36 | 37 | #[cfg_prop(default(0))] 38 | #[gbuilder(default(0))] 39 | line_spacing: u8, 40 | } 41 | } 42 | 43 | impl Default for TextProperty { 44 | fn default() -> Self { 45 | TomlTextProperty::default().into() 46 | } 47 | } 48 | 49 | impl TryFromValue for TextProperty {} 50 | 51 | #[derive(Debug, Clone, Default, Deserialize)] 52 | pub enum WrapMode { 53 | #[serde(rename = "word")] 54 | Word, 55 | #[serde(rename = "word-char")] 56 | #[default] 57 | WordChar, 58 | #[serde(rename = "char")] 59 | Char, 60 | } 61 | 62 | impl TryFromValue for WrapMode { 63 | fn try_from_string(value: String) -> Result { 64 | Ok(match value.to_lowercase().as_str() { 65 | "word" => WrapMode::Word, 66 | "char" => WrapMode::Char, 67 | "word-char" | "word_char" => WrapMode::WordChar, 68 | _ => Err(shared::error::ConversionError::InvalidValue { 69 | expected: "word, char, word-char or word_char", 70 | actual: value, 71 | })?, 72 | }) 73 | } 74 | } 75 | 76 | #[derive(Debug, Deserialize, Default, Clone)] 77 | pub enum TextStyle { 78 | #[default] 79 | #[serde(rename = "regular")] 80 | Regular, 81 | #[serde(rename = "bold")] 82 | Bold, 83 | #[serde(rename = "italic")] 84 | Italic, 85 | #[serde(rename = "bold italic")] 86 | BoldItalic, 87 | } 88 | 89 | impl TryFromValue for TextStyle { 90 | fn try_from_string(value: String) -> Result { 91 | Ok(match value.to_lowercase().as_str() { 92 | "regular" => TextStyle::Regular, 93 | "bold" => TextStyle::Bold, 94 | "italic" => TextStyle::Italic, 95 | "bold-italic" | "bold_italic" => TextStyle::BoldItalic, 96 | _ => Err(shared::error::ConversionError::InvalidValue { 97 | expected: "regular, bold, italic, bold-italic or bold_italic", 98 | actual: value, 99 | })?, 100 | }) 101 | } 102 | } 103 | 104 | #[derive(Debug, Deserialize, Default, Clone)] 105 | pub enum TextAlignment { 106 | #[serde(rename = "center")] 107 | Center, 108 | #[default] 109 | #[serde(rename = "left")] 110 | Left, 111 | #[serde(rename = "right")] 112 | Right, 113 | } 114 | 115 | impl TryFromValue for TextAlignment { 116 | fn try_from_string(value: String) -> Result { 117 | Ok(match value.to_lowercase().as_str() { 118 | "center" => TextAlignment::Center, 119 | "left" => TextAlignment::Left, 120 | "right" => TextAlignment::Right, 121 | _ => Err(shared::error::ConversionError::InvalidValue { 122 | expected: "center, left or right", 123 | actual: value, 124 | })?, 125 | }) 126 | } 127 | } 128 | 129 | impl TomlTextProperty { 130 | pub(super) fn default_summary() -> Self { 131 | Self { 132 | style: Some(TextStyle::Bold), 133 | alignment: Some(TextAlignment::Center), 134 | ..Default::default() 135 | } 136 | } 137 | } 138 | 139 | #[derive(Debug, Deserialize, Default, Clone)] 140 | pub enum Ellipsize { 141 | #[serde(rename = "start")] 142 | Start, 143 | #[serde(rename = "middle")] 144 | Middle, 145 | #[default] 146 | #[serde(rename = "end")] 147 | End, 148 | #[serde(rename = "none")] 149 | None, 150 | } 151 | 152 | impl TryFromValue for Ellipsize { 153 | fn try_from_string(value: String) -> Result { 154 | Ok(match value.to_lowercase().as_str() { 155 | "middle" => Ellipsize::Middle, 156 | "end" => Ellipsize::End, 157 | _ => Err(shared::error::ConversionError::InvalidValue { 158 | expected: "middle or end", 159 | actual: value, 160 | })?, 161 | }) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /crates/render/src/drawer.rs: -------------------------------------------------------------------------------- 1 | use std::f64::consts::{FRAC_PI_2, PI}; 2 | 3 | use pangocairo::cairo::{Context, ImageSurface, LinearGradient}; 4 | 5 | use crate::{ 6 | color::Color, 7 | types::{Offset, RectSize}, 8 | }; 9 | 10 | pub struct Drawer { 11 | pub(crate) surface: ImageSurface, 12 | pub(crate) context: Context, 13 | } 14 | 15 | impl Drawer { 16 | pub fn create(size: RectSize) -> pangocairo::cairo::Result { 17 | let surface = ImageSurface::create( 18 | pangocairo::cairo::Format::ARgb32, 19 | size.width as i32, 20 | size.height as i32, 21 | )?; 22 | 23 | let context = Context::new(&surface)?; 24 | 25 | Ok(Self { surface, context }) 26 | } 27 | } 28 | 29 | pub(crate) trait MakeRounding { 30 | fn make_rounding( 31 | &self, 32 | offset: Offset, 33 | rect_size: RectSize, 34 | outer_radius: f64, 35 | inner_radius: f64, 36 | ); 37 | } 38 | 39 | impl MakeRounding for Context { 40 | fn make_rounding( 41 | &self, 42 | offset: Offset, 43 | rect_size: RectSize, 44 | mut outer_radius: f64, 45 | mut inner_radius: f64, 46 | ) { 47 | debug_assert!(outer_radius >= inner_radius); 48 | let minimal_threshold = (rect_size.height / 2.0).min(rect_size.width / 2.0); 49 | inner_radius = inner_radius.min(minimal_threshold); 50 | outer_radius = outer_radius.min(minimal_threshold); 51 | 52 | self.arc( 53 | offset.x + outer_radius, 54 | offset.y + outer_radius, 55 | inner_radius, 56 | PI, 57 | -FRAC_PI_2, 58 | ); 59 | self.arc( 60 | offset.x + rect_size.width - outer_radius, 61 | offset.y + outer_radius, 62 | inner_radius, 63 | -FRAC_PI_2, 64 | 0.0, 65 | ); 66 | self.arc( 67 | offset.x + rect_size.width - outer_radius, 68 | offset.y + rect_size.height - outer_radius, 69 | inner_radius, 70 | 0.0, 71 | FRAC_PI_2, 72 | ); 73 | self.arc( 74 | offset.x + outer_radius, 75 | offset.y + rect_size.height - outer_radius, 76 | inner_radius, 77 | FRAC_PI_2, 78 | PI, 79 | ); 80 | } 81 | } 82 | 83 | pub(crate) trait SetSourceColor { 84 | fn set_source_color( 85 | &self, 86 | color: &Color, 87 | frame_size: RectSize, 88 | ) -> pangocairo::cairo::Result<()>; 89 | } 90 | 91 | impl SetSourceColor for Drawer { 92 | fn set_source_color( 93 | &self, 94 | color: &Color, 95 | frame_size: RectSize, 96 | ) -> pangocairo::cairo::Result<()> { 97 | match color { 98 | Color::LinearGradient(linear_gradient) => { 99 | fn dot_product(vec1: [f64; 2], vec2: [f64; 2]) -> f64 { 100 | vec1[0] * vec2[0] + vec1[1] * vec2[1] 101 | } 102 | 103 | let (half_width, half_height) = (frame_size.width / 2.0, frame_size.height / 2.0); 104 | 105 | // INFO: need to find a factor to multiply the x/y offsets to that distance where 106 | // prependicular line hits top left and top right corners. Without it part of area 107 | // will be filled by single non-gradientary color that is not acceptable. 108 | let x_offset = linear_gradient.grad_vector[0] * half_width; 109 | let y_offset = linear_gradient.grad_vector[1] * half_height; 110 | let norm = (x_offset * x_offset + y_offset * y_offset).sqrt(); 111 | let factor = 112 | dot_product([x_offset, y_offset], [half_width, half_height]) / (norm * norm); 113 | 114 | let gradient = LinearGradient::new( 115 | half_width - x_offset * factor, 116 | half_height + y_offset * factor, 117 | half_width + x_offset * factor, 118 | half_height - y_offset * factor, 119 | ); 120 | 121 | let mut offset = 0.0; 122 | for bgra in &linear_gradient.colors { 123 | gradient 124 | .add_color_stop_rgba(offset, bgra.red, bgra.green, bgra.blue, bgra.alpha); 125 | offset += linear_gradient.segment_per_color; 126 | } 127 | 128 | self.context.set_source(gradient)?; 129 | } 130 | Color::Fill(bgra) => self 131 | .context 132 | .set_source_rgba(bgra.red, bgra.green, bgra.blue, bgra.alpha), 133 | } 134 | 135 | Ok(()) 136 | } 137 | } 138 | 139 | impl TryFrom for Vec { 140 | type Error = pangocairo::cairo::BorrowError; 141 | 142 | fn try_from(value: Drawer) -> Result { 143 | let Drawer { 144 | surface, context, .. 145 | } = value; 146 | drop(context); 147 | Ok(surface.take_data()?.to_vec()) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /crates/render/src/widget/image.rs: -------------------------------------------------------------------------------- 1 | use config::display::{GBuilderImageProperty, ImageProperty}; 2 | use linicon::IconPath; 3 | use log::warn; 4 | 5 | use crate::{ 6 | drawer::Drawer, 7 | image::Image, 8 | types::{Offset, RectSize}, 9 | PangoContext, 10 | }; 11 | 12 | use super::{CompileState, Draw, WidgetConfiguration}; 13 | 14 | const DEFAULT_ICON_THEME: &str = "hicolor"; 15 | 16 | #[derive(macros::GenericBuilder, Clone)] 17 | #[gbuilder(name(GBuilderWImage), derive(Clone))] 18 | pub struct WImage { 19 | #[gbuilder(hidden, default(Image::Unknown))] 20 | content: Image, 21 | 22 | #[gbuilder(hidden, default(0))] 23 | width: usize, 24 | #[gbuilder(hidden, default(0))] 25 | height: usize, 26 | 27 | #[gbuilder(use_gbuilder(GBuilderImageProperty), default)] 28 | property: ImageProperty, 29 | } 30 | 31 | impl WImage { 32 | pub fn new() -> Self { 33 | Self { 34 | content: Image::Unknown, 35 | width: 0, 36 | height: 0, 37 | property: Default::default(), 38 | } 39 | } 40 | 41 | pub fn compile( 42 | &mut self, 43 | rect_size: RectSize, 44 | WidgetConfiguration { 45 | notification, 46 | display_config, 47 | override_properties, 48 | .. 49 | }: &WidgetConfiguration, 50 | ) -> CompileState { 51 | /// Look's up nearest freedesktop icons. 52 | fn lookup_freedesktop_icon(icon_name: &str, theme: &str, size: u16) -> Option { 53 | linicon::lookup_icon(icon_name) 54 | .from_theme(theme) 55 | .with_size(size) 56 | .next() 57 | .and_then(|icon| icon.ok()) 58 | } 59 | 60 | if *override_properties { 61 | self.property = display_config.image.clone(); 62 | } 63 | 64 | self.content = notification 65 | .hints 66 | .image_data 67 | .as_ref() 68 | .cloned() 69 | .map(|image_data| Image::from_image_data(image_data, &self.property, &rect_size)) 70 | .or_else(|| { 71 | notification 72 | .hints 73 | .image_path 74 | .as_deref() 75 | .map(std::path::Path::new) 76 | .map(|svg_path| Image::from_svg(svg_path, &self.property, &rect_size)) 77 | }) 78 | .or_else(|| { 79 | if notification.app_icon.is_empty() { 80 | return None; 81 | } 82 | 83 | display_config 84 | .icons 85 | .size 86 | .iter() 87 | .find_map(|size| { 88 | lookup_freedesktop_icon( 89 | ¬ification.app_icon, 90 | &display_config.icons.theme, 91 | *size, 92 | ) 93 | .or_else(|| { 94 | lookup_freedesktop_icon( 95 | ¬ification.app_icon, 96 | DEFAULT_ICON_THEME, 97 | *size, 98 | ) 99 | }) 100 | }) 101 | .map(|icon_path| Image::from_path(&icon_path.path, &self.property, &rect_size)) 102 | }) 103 | .unwrap_or(Image::Unknown); 104 | 105 | self.width = self 106 | .content 107 | .width() 108 | .map(|width| width + self.property.margin.horizontal() as usize) 109 | .unwrap_or(0); 110 | self.height = self 111 | .content 112 | .height() 113 | .map(|height| height + self.property.margin.vertical() as usize) 114 | .unwrap_or(0); 115 | 116 | if self.width > rect_size.width || self.height > rect_size.height { 117 | warn!( 118 | "The image doesn't fit to available space.\ 119 | \nThe image size: width={}, height={}.\ 120 | \nAvailable space: width={}, height={}.", 121 | self.width, self.height, rect_size.width, rect_size.height 122 | ); 123 | return CompileState::Failure; 124 | } 125 | 126 | if self.content.is_exists() { 127 | CompileState::Success 128 | } else { 129 | CompileState::Failure 130 | } 131 | } 132 | 133 | pub fn width(&self) -> usize { 134 | self.width 135 | } 136 | 137 | pub fn height(&self) -> usize { 138 | self.height 139 | } 140 | } 141 | 142 | impl Default for WImage { 143 | fn default() -> Self { 144 | Self::new() 145 | } 146 | } 147 | 148 | impl Draw for WImage { 149 | fn draw_with_offset( 150 | &self, 151 | offset: &Offset, 152 | pango_context: &PangoContext, 153 | drawer: &mut Drawer, 154 | ) -> pangocairo::cairo::Result<()> { 155 | if !self.content.is_exists() { 156 | return Ok(()); 157 | } 158 | 159 | let offset = Offset::from(&self.property.margin) + *offset; 160 | self.content 161 | .draw_with_offset(&offset, pango_context, drawer) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /crates/render/src/color.rs: -------------------------------------------------------------------------------- 1 | use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI}; 2 | 3 | use config::color::{Color as CfgColor, LinearGradient as CfgLinearGradient, Rgba as CfgRgba}; 4 | use shared::value::TryFromValue; 5 | 6 | #[derive(Clone)] 7 | pub enum Color { 8 | LinearGradient(LinearGradient), 9 | Fill(Bgra), 10 | } 11 | 12 | impl Color { 13 | pub fn is_transparent(&self) -> bool { 14 | match self { 15 | Color::LinearGradient(linear_gradient) => { 16 | linear_gradient.colors.iter().all(Bgra::is_transparent) 17 | } 18 | Color::Fill(bgra) => bgra.is_transparent(), 19 | } 20 | } 21 | } 22 | 23 | impl From for Color { 24 | fn from(value: LinearGradient) -> Self { 25 | Color::LinearGradient(value) 26 | } 27 | } 28 | 29 | impl From> for Color { 30 | fn from(value: Bgra) -> Self { 31 | Color::Fill(value) 32 | } 33 | } 34 | 35 | impl From for Color { 36 | fn from(value: CfgColor) -> Self { 37 | match value { 38 | CfgColor::Rgba(rgba) => Bgra::from(rgba).into(), 39 | CfgColor::LinearGradient(linear_gradient) => { 40 | LinearGradient::from(linear_gradient).into() 41 | } 42 | } 43 | } 44 | } 45 | 46 | impl Default for Color { 47 | fn default() -> Self { 48 | Color::Fill(Bgra::default()) 49 | } 50 | } 51 | 52 | impl TryFromValue for Color {} 53 | 54 | #[derive(Clone)] 55 | pub struct LinearGradient { 56 | pub angle: f64, 57 | pub grad_vector: [f64; 2], 58 | pub colors: Vec>, 59 | pub segment_per_color: f64, 60 | } 61 | 62 | impl LinearGradient { 63 | /// 3π/4 64 | const FRAC_3_PI_4: f64 = FRAC_PI_2 + FRAC_PI_4; 65 | 66 | pub fn new(mut angle: i16, mut colors: Vec) -> Self { 67 | if angle < 0 { 68 | angle += ((angle / 360) + 1) * 360; 69 | } 70 | 71 | if angle >= 360 { 72 | angle = angle - (angle / 360) * 360; 73 | } 74 | 75 | if angle >= 180 { 76 | colors.reverse(); 77 | angle -= 180 78 | } 79 | 80 | let angle = (angle as f64).to_radians(); 81 | 82 | let grad_vector = match angle { 83 | x @ 0.0..=FRAC_PI_4 => [1.0, x.tan()], 84 | x @ FRAC_PI_4..FRAC_PI_2 => [1.0 / x.tan(), 1.0], 85 | FRAC_PI_2 => [0.0, 1.0], 86 | x @ FRAC_PI_2..=Self::FRAC_3_PI_4 => [1.0 / x.tan(), 1.0], 87 | x @ Self::FRAC_3_PI_4..=PI => [-1.0, -x.tan()], 88 | _ => unreachable!(), 89 | }; 90 | 91 | let segment_per_color = 1.0 / (colors.len() - 1) as f64; 92 | 93 | Self { 94 | angle, 95 | grad_vector, 96 | colors: colors.into_iter().map(Bgra::from).collect(), 97 | segment_per_color, 98 | } 99 | } 100 | } 101 | 102 | impl From for LinearGradient { 103 | fn from(value: CfgLinearGradient) -> Self { 104 | LinearGradient::new(value.degree, value.colors) 105 | } 106 | } 107 | 108 | /// The RGBA color representation by ARGB format in little endian. 109 | /// 110 | /// The struct was made in as adapter between config Rgba color and cairo's ARgb32 format because 111 | /// the first one is very simple and the second one is complex and accepts only floats. 112 | #[derive(Clone, Copy, Default)] 113 | pub struct Bgra 114 | where 115 | T: Copy + Default, 116 | { 117 | pub blue: T, 118 | pub green: T, 119 | pub red: T, 120 | pub alpha: T, 121 | } 122 | 123 | impl Bgra { 124 | pub fn is_transparent(&self) -> bool { 125 | self.alpha == 0.0 126 | } 127 | } 128 | 129 | impl From<&CfgRgba> for Bgra { 130 | fn from( 131 | &CfgRgba { 132 | red, 133 | green, 134 | blue, 135 | alpha, 136 | }: &CfgRgba, 137 | ) -> Self { 138 | Bgra { 139 | blue: blue as f64 / 255.0, 140 | green: green as f64 / 255.0, 141 | red: red as f64 / 255.0, 142 | alpha: alpha as f64 / 255.0, 143 | } 144 | } 145 | } 146 | 147 | impl From for Bgra { 148 | fn from( 149 | CfgRgba { 150 | red, 151 | green, 152 | blue, 153 | alpha, 154 | }: CfgRgba, 155 | ) -> Self { 156 | Bgra { 157 | blue: blue as f64 / 255.0, 158 | green: green as f64 / 255.0, 159 | red: red as f64 / 255.0, 160 | alpha: alpha as f64 / 255.0, 161 | } 162 | } 163 | } 164 | 165 | impl From> for Bgra { 166 | fn from(value: Bgra) -> Self { 167 | Self { 168 | blue: (value.blue * u16::MAX as f64).round() as u16, 169 | green: (value.green * u16::MAX as f64).round() as u16, 170 | red: (value.red * u16::MAX as f64).round() as u16, 171 | alpha: (value.alpha * u16::MAX as f64).round() as u16, 172 | } 173 | } 174 | } 175 | 176 | impl TryFromValue for Bgra { 177 | fn try_from_string(value: String) -> Result { 178 | >::try_from(value.clone()) 179 | .map(Into::into) 180 | .map_err(|_| shared::error::ConversionError::InvalidValue { 181 | expected: "#RGB, #RRGGBB or #RRGGBBAA", 182 | actual: value, 183 | }) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :zap: Noti 2 | 3 | **Noti** is a lightweight desktop notification daemon for Wayland compositors. It offers a customizable and efficient notification experience, designed for modern Linux desktops. 4 | 5 | ## :star2: Features 6 | 7 | | status | feature | 8 | | :----: | :-------------------- | 9 | | ✅ | Hot-reload | 10 | | ✅ | CLI | 11 | | ✅ | Per-App configuration | 12 | | ✅ | Themes | 13 | | ✅ | Idle | 14 | | ✅ | Custom layout | 15 | | ✅ | Gradients | 16 | | 🚧 | `Do-Not-Disturb` mode | 17 | | 🚧 | History | 18 | | 🚧 | Actions | 19 | | ❌ | Audio | 20 | 21 | ## :rocket: Getting Started 22 | 23 | The best way to get started with **Noti** is the [book](https://noti-rs.github.io/notibook). 24 | 25 | ## :inbox_tray: Installation 26 | 27 | ### Prerequisites 28 | 29 | - Rust and Cargo installed ([rust-lang.org](https://www.rust-lang.org/tools/install)) 30 | - For extended installation: Nightly Rust (`rustup toolchain intall nightly`) 31 | 32 | ### Basic: Install via Cargo 33 | 34 | ```bash 35 | # Install directly from GitHub 36 | cargo install --git https://github.com/noti-rs/noti/ 37 | 38 | # Or clone and install locally 39 | git clone https://github.com/noti-rs/noti.git 40 | cd noti 41 | cargo install --path . 42 | ``` 43 | 44 | ### Extended: Minimal binary size (Nightly Only) 45 | 46 | ```bash 47 | RUSTFLAGS="-Zlocation-detail=none -Zfmt-debug=none" cargo +nightly install -Z build-std=std,panic_abort -Z build-std-features="optimize_for_size" --target x86_64-unknown-linux-gnu --git https://github.com/noti-rs/noti 48 | ``` 49 | 50 | > [!IMPORTANT] 51 | > The application uses `libc` allocator as default to minimize heap usage in runtime. You can turn off this option using `--no-default-features` flag. 52 | 53 | ## :hammer_and_wrench: Configuration 54 | 55 | Noti uses a TOML configuration file located at: 56 | 57 | - `$XDG_CONFIG_HOME/noti/config.toml` 58 | - `~/.config/noti/config.toml` 59 | 60 | **Example configuration**: 61 | 62 | ```toml 63 | [general] 64 | font = "JetBrainsMono Nerd Font" 65 | anchor = "top-right" 66 | offset = [15, 15] 67 | gap = 10 68 | sorting = "urgency" 69 | 70 | width = 300 71 | height = 150 72 | 73 | [display] 74 | theme = "pastel" 75 | padding = 8 76 | timeout = 2000 77 | 78 | [display.border] 79 | size = 4 80 | radius = 10 81 | 82 | [display.image] 83 | max_size = 64 84 | margin = { right = 25 } 85 | # For old computers you can use simpler resizing method 86 | # resizing_method = "nearest" 87 | 88 | [display.text] 89 | wrap = false 90 | ellipsize_at = "middle" 91 | 92 | [display.summary] 93 | style = "bold italic" 94 | margin = { top = 5 } 95 | font_size = 18 96 | 97 | [display.body] 98 | justification = "left" 99 | margin = { top = 12 } 100 | font_size = 16 101 | 102 | [[theme]] 103 | name = "pastel" 104 | 105 | [theme.normal] 106 | background = "#1e1e2e" 107 | foreground = "#99AEB3" 108 | border = "#000" 109 | 110 | [theme.critical] 111 | background = "#EBA0AC" 112 | foreground = "#1E1E2E" 113 | border = "#000" 114 | 115 | [[app]] 116 | name = "Telegram Desktop" 117 | [app.display] 118 | border = { radius = 8 } 119 | markup = true 120 | 121 | [app.display.body] 122 | justification = "center" 123 | line_spacing = 5 124 | ``` 125 | 126 | > [!TIP] 127 | > Check the [book](https://noti-rs.github.io/notibook) for comprehensive configuration guide! 128 | 129 | ### :wrench: Custom layout 130 | 131 | Want to change the banner layout? 132 | `Noti` offers a customizable layout using our file format, `.noti`! 133 | 134 | Example of layout configuration: 135 | 136 | ```noti 137 | FlexContainer( 138 | direction = vertical, 139 | spacing = Spacing( 140 | top = 10, 141 | right = 15, 142 | bottom = 10, 143 | left = 15, 144 | ), 145 | alignment = Alignment( 146 | horizontal = center, 147 | vertical = center, 148 | ), 149 | border = Border( 150 | size = 5, 151 | radius = 10, 152 | ), 153 | ) { 154 | Text( 155 | kind = summary, 156 | wrap = false, 157 | ellipsize_at = end, 158 | justification = center, 159 | ) 160 | Text( 161 | kind = body, 162 | style = bold-italic, 163 | margin = Spacing(top = 12), 164 | justification = center, 165 | ) 166 | } 167 | ``` 168 | 169 | To enable this feature, write your own layout in file and in main config file write: 170 | 171 | ```toml 172 | display.layout = "path/to/your/File.noti" 173 | ``` 174 | 175 | Read more about it [here](https://noti-rs.github.io/notibook/CustomLayout.html)! 176 | 177 | ## :bug: Troubleshooting 178 | 179 | Having issues? 180 | 181 | - Set the `NOTI_LOG` environment variable to `debug` or `trace` for detailed logs: 182 | 183 | ```bash 184 | NOTI_LOG=debug noti run >> debug.log 185 | ``` 186 | 187 | - Open a GitHub issue and attach the log file. This will help us resolve the problem faster. 188 | 189 | ## :handshake: Contributing 190 | 191 | Interested in improving **Noti**? Here's how to contribute: 192 | 193 | 1. Fork the repo and create your branch: 194 | 195 | ```bash 196 | git checkout -b feature/my-improvement 197 | ``` 198 | 199 | 2. Make your changes and commit them: 200 | 201 | ```bash 202 | git commit -am "feat: describe your changes" 203 | ``` 204 | 205 | 3. Push your changes: 206 | 207 | ```bash 208 | git push origin feature/my-improvement 209 | ``` 210 | 211 | 4. Open a Pull Request 212 | 213 | > [!NOTE] 214 | > For major changes, please open an issue first to discuss the changes you'd like to make. 215 | 216 | ## 📄 License 217 | 218 | **Noti** is licensed under the GNU General Public License v3.0 (GPL-3.0). 219 | 220 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 221 | 222 | See the [LICENSE](LICENSE) file for complete details. 223 | 224 | ## 📬 Contact 225 | 226 | Have questions or need support? We're here to help! Open an issue on GitHub and we'll get back to you as soon as possible. 227 | -------------------------------------------------------------------------------- /assets/logo512.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /crates/client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | use log::debug; 3 | use std::collections::HashMap; 4 | use zbus::zvariant::Value; 5 | 6 | pub struct HintsData { 7 | pub urgency: Option, 8 | pub category: Option, 9 | pub desktop_entry: Option, 10 | pub image_path: Option, 11 | pub resident: Option, 12 | pub sound_file: Option, 13 | pub sound_name: Option, 14 | pub suppress_sound: Option, 15 | pub transient: Option, 16 | pub action_icons: Option, 17 | pub schedule: Option, 18 | } 19 | 20 | pub struct NotiClient<'a> { 21 | dbus_client: dbus::client::Client<'a>, 22 | } 23 | 24 | impl NotiClient<'_> { 25 | pub async fn init() -> anyhow::Result { 26 | let client = dbus::client::Client::init().await?; 27 | Ok(Self { 28 | dbus_client: client, 29 | }) 30 | } 31 | 32 | #[allow(clippy::too_many_arguments)] 33 | pub async fn send_notification( 34 | &self, 35 | id: u32, 36 | app_name: String, 37 | icon: String, 38 | summary: String, 39 | body: String, 40 | timeout: i32, 41 | actions: Vec, 42 | hints: Vec, 43 | hints_data: HintsData, 44 | ) -> anyhow::Result { 45 | debug!("Client: Building hints and actions from user prompt"); 46 | let new_hints = build_hints(&hints, hints_data)?; 47 | 48 | let actions = build_actions(&actions)?; 49 | 50 | debug!( 51 | "Client: Send notification with metadata:\n\ 52 | \treplaces_id - {id},\n\ 53 | \tapp_name - {app_name},\n\ 54 | \tapp_icon - {icon},\n\ 55 | \tsummary - {summary},\n\ 56 | \tbody - {body},\n\ 57 | \tactions - [{actions}],\n\ 58 | \thints - {{ {new_hints} }},\n\ 59 | \ttimeout - {timeout}", 60 | actions = actions.join(", "), 61 | new_hints = new_hints 62 | .iter() 63 | .map(|(k, v)| k.to_string() + ": " + &v.to_string()) 64 | .collect::>() 65 | .join(", ") 66 | ); 67 | 68 | let notification_id = self 69 | .dbus_client 70 | .notify( 71 | &app_name, id, &icon, &summary, &body, actions, new_hints, timeout, 72 | ) 73 | .await?; 74 | 75 | debug!("Client: Successful send"); 76 | 77 | Ok(notification_id) 78 | } 79 | 80 | pub async fn get_server_info(&self) -> anyhow::Result<()> { 81 | debug!("Client: Trying to request server information"); 82 | let server_info = self.dbus_client.get_server_information().await?; 83 | debug!("Client: Received server information"); 84 | 85 | println!( 86 | "Name: {}\nVendor: {}\nVersion: {}\nSpecification version: {}", 87 | server_info.0, server_info.1, server_info.2, server_info.3 88 | ); 89 | 90 | Ok(()) 91 | } 92 | } 93 | 94 | fn build_actions(actions: &[String]) -> anyhow::Result> { 95 | let mut new_actions = Vec::with_capacity(actions.len() * 2); 96 | 97 | for entry in actions { 98 | if let Some((action_name, action_desc)) = entry.split_once(':') { 99 | new_actions.push(action_name.trim()); 100 | new_actions.push(action_desc.trim()); 101 | } else { 102 | bail!( 103 | "Invalid action format for entry '{}'. Expected format: 'name:desc'", 104 | entry 105 | ); 106 | } 107 | } 108 | 109 | Ok(new_actions) 110 | } 111 | 112 | fn build_hints<'a>( 113 | hints: &'a [String], 114 | hints_data: HintsData, 115 | ) -> anyhow::Result>> { 116 | let mut hints_map: HashMap<&'a str, Value<'a>> = HashMap::with_capacity(hints.len()); 117 | 118 | for entry in hints { 119 | let parts: Vec<&'a str> = entry.split(':').collect(); 120 | 121 | if parts.len() == 3 { 122 | let hint_type = parts[0].trim(); 123 | let hint_name = parts[1].trim(); 124 | let hint_value = parts[2].trim(); 125 | 126 | let value = parse_hint_value(hint_type, hint_value)?; 127 | hints_map.insert(hint_name, value); 128 | } else { 129 | bail!( 130 | "Invalid hint format for entry '{}'. Expected format: 'type:name:value'", 131 | entry 132 | ); 133 | } 134 | } 135 | 136 | hints_map.insert_if_empty("urgency", hints_data.urgency, Value::from); 137 | hints_map.insert_if_empty("category", hints_data.category, Value::from); 138 | hints_map.insert_if_empty("desktop-entry", hints_data.desktop_entry, Value::from); 139 | hints_map.insert_if_empty("image-path", hints_data.image_path, Value::from); 140 | hints_map.insert_if_empty("sound-file", hints_data.sound_file, Value::from); 141 | hints_map.insert_if_empty("sound-name", hints_data.sound_name, Value::from); 142 | hints_map.insert_if_empty("schedule", hints_data.schedule, Value::from); 143 | hints_map.insert_if_empty("resident", hints_data.resident, Value::Bool); 144 | hints_map.insert_if_empty("suppress-sound", hints_data.suppress_sound, Value::Bool); 145 | hints_map.insert_if_empty("transient", hints_data.transient, Value::Bool); 146 | hints_map.insert_if_empty("action-icons", hints_data.action_icons, Value::Bool); 147 | 148 | Ok(hints_map) 149 | } 150 | 151 | fn parse_hint_value<'a>(hint_type: &'_ str, hint_value: &'a str) -> anyhow::Result> { 152 | Ok(match hint_type { 153 | "int" => Value::I32(hint_value.parse()?), 154 | "byte" => Value::U8(hint_value.parse()?), 155 | "bool" => Value::Bool(hint_value.parse()?), 156 | "string" => Value::from(hint_value), 157 | _ => anyhow::bail!( 158 | "Invalid hint type \"{}\". Valid types are int, byte, bool, and string.", 159 | hint_type 160 | ), 161 | }) 162 | } 163 | 164 | trait InsertIfEmpty 165 | where 166 | K: std::cmp::Eq + std::hash::Hash, 167 | { 168 | fn insert_if_empty(&mut self, key: K, value: Option, conversion: F) 169 | where 170 | F: FnOnce(T) -> V; 171 | } 172 | 173 | impl InsertIfEmpty for HashMap 174 | where 175 | K: std::cmp::Eq + std::hash::Hash, 176 | { 177 | fn insert_if_empty<'b, T, F>(&mut self, key: K, value: Option, conversion: F) 178 | where 179 | F: FnOnce(T) -> V, 180 | { 181 | let Some(value) = value else { 182 | return; 183 | }; 184 | 185 | self.entry(key).or_insert_with(|| conversion(value)); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /crates/dbus/src/server.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | actions::{Action, ClosingReason, Signal}, 3 | notification::{Hints, Notification, NotificationAction, Timeout}, 4 | text::Text, 5 | }; 6 | 7 | use std::{ 8 | collections::HashMap, 9 | sync::atomic::{AtomicU32, Ordering}, 10 | time::{SystemTime, UNIX_EPOCH}, 11 | }; 12 | 13 | use async_channel::Sender; 14 | use log::debug; 15 | use zbus::{ 16 | connection, fdo::Result, interface, object_server::SignalEmitter, zvariant::Value, Connection, 17 | }; 18 | 19 | static UNIQUE_ID: AtomicU32 = AtomicU32::new(1); 20 | 21 | pub struct Server { 22 | connection: Connection, 23 | } 24 | 25 | impl Server { 26 | const NOTIFICATIONS_PATH: &'static str = "/org/freedesktop/Notifications"; 27 | const NOTIFICATIONS_NAME: &'static str = "org.freedesktop.Notifications"; 28 | 29 | pub async fn init(sender: Sender) -> anyhow::Result { 30 | debug!("D-Bus Server: Initializing"); 31 | 32 | let handler = Handler { sender }; 33 | 34 | let connection = connection::Builder::session()? 35 | .name(Self::NOTIFICATIONS_NAME)? 36 | .serve_at(Self::NOTIFICATIONS_PATH, handler)? 37 | .build() 38 | .await?; 39 | 40 | debug!("D-Bus Server: Initialized"); 41 | 42 | Ok(Self { connection }) 43 | } 44 | 45 | pub async fn emit_signal(&self, signal: Signal) -> zbus::Result<()> { 46 | debug!("D-Bus Server: Emitting signal {signal}"); 47 | 48 | let ctxt = SignalEmitter::new(&self.connection, Self::NOTIFICATIONS_PATH)?; 49 | match signal { 50 | Signal::NotificationClosed { 51 | notification_id, 52 | reason, 53 | } => { 54 | let id = match notification_id { 55 | 0 => UNIQUE_ID.load(Ordering::Relaxed), 56 | _ => notification_id, 57 | }; 58 | 59 | Handler::notification_closed(&ctxt, id, u32::from(reason)).await 60 | } 61 | Signal::ActionInvoked { 62 | notification_id, 63 | action_key, 64 | } => Handler::action_invoked(&ctxt, notification_id, &action_key).await, 65 | } 66 | } 67 | } 68 | 69 | struct Handler { 70 | sender: Sender, 71 | } 72 | 73 | #[interface(name = "org.freedesktop.Notifications")] 74 | impl Handler { 75 | #[allow(clippy::too_many_arguments)] 76 | async fn notify( 77 | &mut self, 78 | app_name: String, 79 | replaces_id: u32, 80 | app_icon: String, 81 | summary: String, 82 | body: String, 83 | actions: Vec<&str>, 84 | hints: HashMap<&str, Value<'_>>, 85 | expire_timeout: i32, 86 | ) -> Result { 87 | debug!("D-Bus Server: Received notification"); 88 | 89 | let id = match replaces_id { 90 | 0 => UNIQUE_ID.fetch_add(1, Ordering::Relaxed), 91 | _ => replaces_id, 92 | }; 93 | 94 | #[rustfmt::skip] 95 | let created_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); 96 | let hints = Hints::from(hints); 97 | let actions = NotificationAction::from_vec(&actions); 98 | let body = Text::parse(body); 99 | let expire_timeout = Timeout::from(expire_timeout); 100 | 101 | let notification = Notification { 102 | id, 103 | app_name, 104 | app_icon, 105 | summary, 106 | body, 107 | hints, 108 | actions, 109 | expire_timeout, 110 | created_at, 111 | is_read: false, 112 | }; 113 | 114 | if let Some(schedule) = ¬ification.hints.schedule { 115 | let scheduled_notification = crate::notification::ScheduledNotification { 116 | id: notification.id, 117 | time: schedule.to_owned(), 118 | data: notification.into(), 119 | }; 120 | 121 | self.sender 122 | .send(Action::Schedule(scheduled_notification)) 123 | .await 124 | .unwrap(); 125 | } else { 126 | self.sender 127 | .send(Action::Show(notification.into())) 128 | .await 129 | .unwrap(); 130 | } 131 | 132 | Ok(id) 133 | } 134 | 135 | async fn close_notification( 136 | &self, 137 | id: u32, 138 | #[zbus(signal_context)] ctxt: SignalEmitter<'_>, 139 | ) -> Result<()> { 140 | debug!("D-Bus Server: Called method 'CloseNotification' by id {id}"); 141 | Self::notification_closed(&ctxt, id, ClosingReason::CallCloseNotification.into()).await?; 142 | self.sender.send(Action::Close(Some(id))).await.unwrap(); 143 | 144 | Ok(()) 145 | } 146 | 147 | // NOTE: temporary 148 | async fn close_last_notification( 149 | &self, 150 | #[zbus(signal_context)] ctxt: SignalEmitter<'_>, 151 | ) -> Result<()> { 152 | debug!("D-Bus Server: Called method 'CloseLastNotification'"); 153 | //WARNING: temporary id value 154 | Self::notification_closed(&ctxt, 0, ClosingReason::CallCloseNotification.into()).await?; 155 | self.sender.send(Action::Close(None)).await.unwrap(); 156 | 157 | Ok(()) 158 | } 159 | 160 | async fn get_server_information(&self) -> Result<(String, String, String, String)> { 161 | debug!("D-Bus Server: Called method 'GetServerInformation'"); 162 | let name = String::from(env!("APP_NAME")); 163 | let vendor = String::from(env!("CARGO_PKG_AUTHORS")); 164 | let version = String::from(env!("CARGO_PKG_VERSION")); 165 | let specification_version = String::from("1.2"); 166 | 167 | Ok((name, vendor, version, specification_version)) 168 | } 169 | 170 | async fn get_capabilities(&self) -> Result> { 171 | debug!("D-Bus Server: Called method 'GetCapabilities'"); 172 | let capabilities = vec![ 173 | String::from("action-icons"), 174 | String::from("actions"), 175 | String::from("body"), 176 | String::from("body-hyperlinks"), 177 | String::from("body-images"), 178 | String::from("body-markup"), 179 | String::from("icon-multi"), 180 | String::from("icon-static"), 181 | String::from("persistence"), 182 | String::from("sound"), 183 | ]; 184 | 185 | Ok(capabilities) 186 | } 187 | 188 | #[zbus(signal)] 189 | async fn action_invoked( 190 | ctxt: &SignalEmitter<'_>, 191 | id: u32, 192 | action_key: &str, 193 | ) -> zbus::Result<()>; 194 | 195 | #[zbus(signal)] 196 | async fn notification_closed( 197 | ctxt: &SignalEmitter<'_>, 198 | id: u32, 199 | reason: u32, 200 | ) -> zbus::Result<()>; 201 | } 202 | -------------------------------------------------------------------------------- /crates/app/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use config::Config; 3 | 4 | /// The notification system which derives a notification to user 5 | /// using wayland client. 6 | #[derive(Parser)] 7 | #[command(version, about, name = env!("APP_NAME"))] 8 | pub enum Args { 9 | /// Start the backend. Use it in systemd, openrc or any other service. 10 | Run(Box), 11 | 12 | /// Send the notification 13 | Send(Box), 14 | 15 | /// Print server information 16 | ServerInfo, 17 | } 18 | 19 | #[derive(Parser)] 20 | pub struct RunCommand { 21 | #[arg( 22 | short, 23 | long, 24 | help = "Path to config file", 25 | long_help = "Path to config file which will be used primarily" 26 | )] 27 | config: Option, 28 | } 29 | 30 | #[derive(Parser)] 31 | pub struct SendCommand { 32 | #[arg(help = "Summary", long_help = "Summary of the notification")] 33 | summary: String, 34 | 35 | #[arg( 36 | default_value_t = String::from(""), 37 | hide_default_value = true, 38 | help = "Body", 39 | long_help = "Body text of the notification" 40 | )] 41 | body: String, 42 | 43 | #[arg( 44 | short, 45 | long, 46 | default_value_t = String::from("Noti"), 47 | hide_default_value = true, 48 | help = "The name of the application" 49 | )] 50 | app_name: String, 51 | 52 | #[arg( 53 | short, 54 | long, 55 | default_value_t = 0, 56 | hide_default_value = true, 57 | help = "ID", 58 | long_help = "ID of the notification to replace" 59 | )] 60 | replaces_id: u32, 61 | 62 | #[arg( 63 | short, 64 | long, 65 | default_value_t = String::from(""), 66 | hide_default_value = true, 67 | help = "Icon", 68 | long_help = "Path to the icon file" 69 | )] 70 | icon: String, 71 | 72 | #[arg(short, long, default_value_t = -1, hide_default_value = true, help = "Timeout in milliseconds")] 73 | timeout: i32, 74 | 75 | #[arg( 76 | short, 77 | long, 78 | help = "Prints ID", 79 | long_help = "Prints the ID of created notification" 80 | )] 81 | print_id: bool, 82 | 83 | #[arg( 84 | short = 'A', 85 | long, 86 | help = "Actions", 87 | long_help = "Extra actions that define interactive options for the notification" 88 | )] 89 | actions: Vec, 90 | 91 | #[arg( 92 | short = 'H', 93 | long, 94 | help = "Hints", 95 | long_help = "Extra hints that modify notification behavior" 96 | )] 97 | hints: Vec, 98 | 99 | #[arg(short, long, help = "Urgency level (low, normal, critical)")] 100 | urgency: Option, 101 | 102 | #[arg( 103 | short, 104 | long, 105 | help = "Notification category", 106 | long_help = "The type of notification this is" 107 | )] 108 | category: Option, 109 | 110 | #[arg( 111 | short, 112 | long, 113 | help = "Desktop entry path", 114 | long_help = "Desktop entry filename representing the calling program" 115 | )] 116 | desktop_entry: Option, 117 | 118 | #[arg( 119 | short = 'I', 120 | long, 121 | help = "Image file", 122 | long_help = "Path to image file" 123 | )] 124 | image_path: Option, 125 | 126 | #[arg( 127 | short = 'R', 128 | long, 129 | help = "Resident", 130 | long_help = "Prevents automatic removal of notifications after an action" 131 | )] 132 | resident: Option, 133 | 134 | #[arg( 135 | long, 136 | help = "Sound file", 137 | long_help = "Path to a sound file to play when the notification pops up" 138 | )] 139 | sound_file: Option, 140 | 141 | #[arg( 142 | short = 'N', 143 | long, 144 | help = "Sound name", 145 | long_help = "A themeable sound name to play when the notification pops up" 146 | )] 147 | sound_name: Option, 148 | 149 | #[arg( 150 | short = 'S', 151 | long, 152 | help = "Suppress sound", 153 | long_help = "Causes the server to suppress playing any sounds" 154 | )] 155 | suppress_sound: Option, 156 | 157 | #[arg( 158 | short = 'T', 159 | long, 160 | help = "Transient", 161 | long_help = "Marks the notification as transient, bypassing the server's persistence capability if available" 162 | )] 163 | transient: Option, 164 | 165 | #[arg( 166 | short = 'C', 167 | long, 168 | help = "Action icons", 169 | long_help = "Interprets action IDs as icons, annotated by display names" 170 | )] 171 | action_icons: Option, 172 | 173 | #[arg( 174 | short = 's', 175 | long, 176 | help = "Schedule", 177 | long_help = "Specifies the time to schedule the notification to be shown." 178 | )] 179 | schedule: Option, 180 | } 181 | 182 | impl Args { 183 | pub fn process(self) -> anyhow::Result<()> { 184 | if let Args::Run(ref args) = self { 185 | run(args)? 186 | } 187 | 188 | async_io::block_on(async { 189 | let noti = client::NotiClient::init().await?; 190 | 191 | match self { 192 | Args::Run { .. } => unreachable!(), 193 | Args::Send(args) => send(noti, *args).await?, 194 | Args::ServerInfo => server_info(noti).await?, 195 | } 196 | 197 | Ok::<(), anyhow::Error>(()) 198 | }) 199 | } 200 | } 201 | 202 | fn run(args: &RunCommand) -> anyhow::Result<()> { 203 | let config = Config::init(args.config.as_deref()); 204 | backend::run(config) 205 | } 206 | 207 | async fn send(noti: client::NotiClient<'_>, args: SendCommand) -> anyhow::Result<()> { 208 | let hints_data = client::HintsData { 209 | urgency: args.urgency, 210 | category: args.category, 211 | desktop_entry: args.desktop_entry, 212 | image_path: args.image_path, 213 | sound_file: args.sound_file, 214 | sound_name: args.sound_name, 215 | resident: args.resident, 216 | suppress_sound: args.suppress_sound, 217 | transient: args.transient, 218 | action_icons: args.action_icons, 219 | schedule: args.schedule, 220 | }; 221 | 222 | let notification_id = noti 223 | .send_notification( 224 | args.replaces_id, 225 | args.app_name, 226 | args.icon, 227 | args.summary, 228 | args.body, 229 | args.timeout, 230 | args.actions, 231 | args.hints, 232 | hints_data, 233 | ) 234 | .await?; 235 | 236 | if args.print_id { 237 | print!("{notification_id}") 238 | } 239 | 240 | Ok(()) 241 | } 242 | 243 | async fn server_info(noti: client::NotiClient<'_>) -> anyhow::Result<()> { 244 | noti.get_server_info().await 245 | } 246 | -------------------------------------------------------------------------------- /crates/shared/src/file_watcher.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use inotify::{EventMask, Inotify, WatchDescriptor, WatchMask}; 4 | use log::debug; 5 | 6 | const DEFAULT_MASKS: WatchMask = WatchMask::MOVE_SELF 7 | .union(WatchMask::DELETE_SELF) 8 | .union(WatchMask::MODIFY); 9 | 10 | #[derive(Debug)] 11 | pub struct FilesWatcher { 12 | inotify: Inotify, 13 | paths: Vec, 14 | config_wd: Option, 15 | } 16 | 17 | /// The struct that can detect file changes using inotify. 18 | impl FilesWatcher { 19 | /// Creates a `FilesWatcher` struct from provided paths. 20 | /// 21 | /// The paths are **arranged**. It's means that first path is more prioritized than second and 22 | /// second is more prioritized than third and so on. 23 | pub fn init>(paths: Vec) -> anyhow::Result { 24 | assert!( 25 | !paths.is_empty(), 26 | "At least one path should be provided to FilesWatcher" 27 | ); 28 | 29 | debug!("Watcher: Initializing"); 30 | let inotify = Inotify::init()?; 31 | 32 | let paths: Vec = paths.into_iter().map(From::from).collect(); 33 | debug!( 34 | "Watcher: Received paths - {paths}", 35 | paths = paths 36 | .iter() 37 | .map(|p| p.path_buf.display().to_string()) 38 | .collect::>() 39 | .join(", ") 40 | ); 41 | 42 | let config_wd = paths 43 | .iter() 44 | .find(|path| path.is_file()) 45 | .map(|path| inotify.new_wd(path)); 46 | 47 | debug!("Watcher: Initialized"); 48 | Ok(Self { 49 | inotify, 50 | paths, 51 | config_wd, 52 | }) 53 | } 54 | 55 | pub fn get_watching_path(&self) -> Option<&Path> { 56 | self.config_wd 57 | .as_ref() 58 | .map(|config_wd| config_wd.path_buf.as_path()) 59 | } 60 | 61 | pub fn check_updates(&mut self) -> FileState { 62 | let state = if let Some(file_path) = self.paths.iter().find(|path| path.is_file()) { 63 | if self 64 | .config_wd 65 | .as_ref() 66 | .is_some_and(|config_wd| file_path.path_buf == config_wd.path_buf) 67 | { 68 | let state = self.inotify.handle_events(); 69 | 70 | if state.is_not_found() { 71 | self.inotify.destroy_wd(self.config_wd.take()); 72 | // INFO: the file is found but the watch descriptor says that the files is 73 | // moved or deleted. As I understand right, we don't give a fuck what is 74 | // earlier and use the path above as 'last thing that checked' and create new 75 | // WatchDescriptor. 76 | self.config_wd = Some(self.inotify.new_wd(file_path)); 77 | FileState::Updated 78 | } else { 79 | state 80 | } 81 | } else { 82 | self.inotify.destroy_wd(self.config_wd.take()); 83 | // INFO: same as above 84 | self.config_wd = Some(self.inotify.new_wd(file_path)); 85 | FileState::Updated 86 | } 87 | } else { 88 | self.inotify.destroy_wd(self.config_wd.take()); 89 | FileState::NotFound 90 | }; 91 | 92 | state 93 | } 94 | } 95 | 96 | #[derive(Debug)] 97 | pub enum FileState { 98 | Updated, 99 | NotFound, 100 | NothingChanged, 101 | } 102 | 103 | impl FileState { 104 | fn is_not_found(&self) -> bool { 105 | matches!(self, FileState::NotFound) 106 | } 107 | 108 | fn priority(&self) -> u8 { 109 | match self { 110 | FileState::Updated => 2, 111 | FileState::NotFound => 1, 112 | FileState::NothingChanged => 0, 113 | } 114 | } 115 | } 116 | 117 | impl std::ops::BitOr for FileState { 118 | type Output = Self; 119 | 120 | fn bitor(self, rhs: Self) -> Self::Output { 121 | match self.priority().cmp(&rhs.priority()) { 122 | std::cmp::Ordering::Less => rhs, 123 | std::cmp::Ordering::Greater | std::cmp::Ordering::Equal => self, 124 | } 125 | } 126 | } 127 | 128 | /// The config watch descriptor 129 | #[derive(Debug)] 130 | struct FileWd { 131 | wd: WatchDescriptor, 132 | path_buf: PathBuf, 133 | } 134 | 135 | impl FileWd { 136 | fn from_wd(watch_descriptor: WatchDescriptor, path: PathBuf) -> Self { 137 | Self { 138 | wd: watch_descriptor, 139 | path_buf: path, 140 | } 141 | } 142 | } 143 | 144 | #[derive(Debug)] 145 | struct FilePath { 146 | path_buf: PathBuf, 147 | } 148 | 149 | impl FilePath { 150 | fn is_file(&self) -> bool { 151 | self.path_buf.is_file() 152 | } 153 | 154 | fn as_path(&self) -> &Path { 155 | self.path_buf.as_path() 156 | } 157 | } 158 | 159 | impl> From for FilePath { 160 | fn from(value: T) -> Self { 161 | FilePath { 162 | path_buf: value.as_ref().to_path_buf(), 163 | } 164 | } 165 | } 166 | 167 | trait InotifySpecialization { 168 | fn new_wd(&self, path: &FilePath) -> FileWd; 169 | fn destroy_wd(&self, config_wd: Option); 170 | 171 | fn handle_events(&mut self) -> FileState; 172 | } 173 | 174 | impl InotifySpecialization for Inotify { 175 | fn new_wd(&self, file_path: &FilePath) -> FileWd { 176 | let new_wd = self 177 | .watches() 178 | .add(file_path.as_path(), DEFAULT_MASKS) 179 | .unwrap_or_else(|_| { 180 | panic!( 181 | "Failed to create watch descriptor for config path {file_path}", 182 | file_path = file_path.path_buf.display() 183 | ) 184 | }); 185 | 186 | FileWd::from_wd(new_wd, file_path.path_buf.clone()) 187 | } 188 | 189 | fn destroy_wd(&self, config_wd: Option) { 190 | if let Some(config_wd) = config_wd { 191 | let _ = self.watches().remove(config_wd.wd); 192 | } 193 | } 194 | 195 | fn handle_events(&mut self) -> FileState { 196 | let mut buffer = [0; 4096]; 197 | 198 | match self.read_events(&mut buffer) { 199 | Ok(events) => events 200 | .into_iter() 201 | .map(|event| { 202 | if event.mask.contains(EventMask::MODIFY) { 203 | FileState::Updated 204 | } else if event.mask.contains(EventMask::DELETE_SELF) 205 | || event.mask.contains(EventMask::MOVE_SELF) 206 | { 207 | FileState::NotFound 208 | } else { 209 | FileState::NothingChanged 210 | } 211 | }) 212 | .fold(FileState::NothingChanged, |lhs, rhs| lhs | rhs), 213 | Err(_) => FileState::NothingChanged, 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /crates/macros/src/general.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use quote::ToTokens; 4 | use syn::{ext::IdentExt, parse::Parse, spanned::Spanned, Ident, Token}; 5 | 6 | #[derive(Clone)] 7 | pub struct Structure { 8 | pub attributes: Vec, 9 | pub visibility: syn::Visibility, 10 | pub struct_token: Token![struct], 11 | pub name: syn::Ident, 12 | pub braces: syn::token::Brace, 13 | pub fields: syn::punctuated::Punctuated, 14 | } 15 | 16 | impl Parse for Structure { 17 | fn parse(input: syn::parse::ParseStream) -> syn::Result { 18 | let content; 19 | Ok(Self { 20 | attributes: input.call(syn::Attribute::parse_outer)?, 21 | visibility: input.parse()?, 22 | struct_token: input.parse()?, 23 | name: input.parse()?, 24 | braces: syn::braced!(content in input), 25 | fields: content.parse_terminated(syn::Field::parse_named, Token![,])?, 26 | }) 27 | } 28 | } 29 | 30 | pub struct AttributeInfo 31 | where 32 | S: Parse, 33 | F: Parse, 34 | { 35 | pub struct_info: S, 36 | pub fields_info: HashMap, 37 | } 38 | 39 | impl AttributeInfo 40 | where 41 | S: Parse, 42 | F: Parse, 43 | { 44 | pub(crate) fn parse_removal( 45 | structure: &mut Structure, 46 | attribute_name: &str, 47 | ) -> syn::Result { 48 | fn remove_suitable_attributes( 49 | attributes: &mut Vec, 50 | attribute_name: &str, 51 | ) -> Option { 52 | let index = attributes 53 | .iter() 54 | .enumerate() 55 | .find_map(|(i, attribute)| matches(attribute, attribute_name).then_some(i)); 56 | 57 | index.map(|index| attributes.remove(index)) 58 | } 59 | 60 | fn attribute_tokens( 61 | attribute: syn::Attribute, 62 | attribute_name: &str, 63 | ) -> syn::Result { 64 | let span = attribute.span(); 65 | if let syn::Meta::List(meta_list) = attribute.meta { 66 | if let syn::MacroDelimiter::Paren(_) = &meta_list.delimiter { 67 | Ok(meta_list.tokens) 68 | } else { 69 | Err(syn::Error::new( 70 | span, 71 | "Expected parenthesis, not brackets or braces!", 72 | )) 73 | } 74 | } else { 75 | Err(syn::Error::new( 76 | span, 77 | format!("Expected attribute like #[{attribute_name}()]"), 78 | )) 79 | } 80 | } 81 | 82 | let Some(outer_attribute) = 83 | remove_suitable_attributes(&mut structure.attributes, attribute_name) 84 | else { 85 | return Err(syn::Error::new( 86 | proc_macro2::Span::call_site(), 87 | format!("Expected #[{attribute_name}(name(StructName))] as outer attribute but it isn't provided"), 88 | )); 89 | }; 90 | let struct_info = syn::parse2(attribute_tokens(outer_attribute, attribute_name)?)?; 91 | 92 | let mut fields_info = HashMap::new(); 93 | for field in structure.fields.iter_mut() { 94 | let field_name = field_name(field); 95 | let Some(field_attribute) = 96 | remove_suitable_attributes(&mut field.attrs, attribute_name) 97 | else { 98 | continue; 99 | }; 100 | 101 | fields_info.insert( 102 | field_name, 103 | syn::parse2(attribute_tokens(field_attribute, attribute_name)?)?, 104 | ); 105 | } 106 | 107 | Ok(Self { 108 | struct_info, 109 | fields_info, 110 | }) 111 | } 112 | } 113 | 114 | fn matches(attribute: &syn::Attribute, attribute_name: &str) -> bool { 115 | if let syn::Meta::List(meta_list) = &attribute.meta { 116 | if meta_list.path.is_ident(attribute_name) { 117 | return true; 118 | } 119 | } 120 | 121 | false 122 | } 123 | pub(crate) fn field_name(field: &syn::Field) -> String { 124 | field.expect_ident().to_string() 125 | } 126 | 127 | pub(crate) enum DefaultAssignment { 128 | DefaultCall, 129 | Expression(syn::Expr), 130 | FunctionCall(syn::Path), 131 | } 132 | 133 | impl Parse for DefaultAssignment { 134 | fn parse(input: syn::parse::ParseStream) -> syn::Result { 135 | Ok(if input.peek(Ident::peek_any) && input.peek2(Token![=]) { 136 | let ident = input.parse::()?; 137 | if ident != "path" { 138 | return Err(syn::Error::new( 139 | ident.span(), 140 | format!("Expected 'path' for function path, but given {ident}"), 141 | )); 142 | } 143 | 144 | let _ = input.parse::()?; 145 | DefaultAssignment::FunctionCall(input.parse()?) 146 | } else { 147 | DefaultAssignment::Expression(input.parse()?) 148 | }) 149 | } 150 | } 151 | 152 | pub struct DeriveInfo { 153 | ident: syn::Ident, 154 | paren: syn::token::Paren, 155 | traits: syn::punctuated::Punctuated, 156 | } 157 | 158 | impl DeriveInfo { 159 | pub fn from_ident_and_input( 160 | ident: syn::Ident, 161 | input: &syn::parse::ParseStream, 162 | ) -> syn::Result { 163 | let content; 164 | Ok(Self { 165 | ident, 166 | paren: syn::parenthesized!(content in input), 167 | traits: content.parse_terminated(syn::Ident::parse_any, Token![,])?, 168 | }) 169 | } 170 | } 171 | 172 | impl ToTokens for DeriveInfo { 173 | fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { 174 | proc_macro2::Punct::new('#', proc_macro2::Spacing::Joint).to_tokens(tokens); 175 | syn::token::Bracket::default().surround(tokens, |tokens| { 176 | self.ident.to_tokens(tokens); 177 | self.paren 178 | .surround(tokens, |tokens| self.traits.to_tokens(tokens)); 179 | }); 180 | } 181 | } 182 | 183 | pub(crate) trait ExpectIdent { 184 | fn expect_ident(&self) -> &syn::Ident; 185 | } 186 | 187 | impl ExpectIdent for syn::Field { 188 | fn expect_ident(&self) -> &syn::Ident { 189 | self.ident.as_ref().expect("Fields should be named!") 190 | } 191 | } 192 | 193 | pub(crate) fn wrap_by_option(ty: syn::Type) -> syn::Type { 194 | use proc_macro2::Span; 195 | use syn::PathSegment; 196 | 197 | syn::Type::Path(syn::TypePath { 198 | qself: None, 199 | path: syn::Path { 200 | leading_colon: None, 201 | segments: >::from_iter(vec![ 202 | PathSegment { 203 | ident: Ident::new("std", Span::call_site()), 204 | arguments: syn::PathArguments::None, 205 | }, 206 | PathSegment { 207 | ident: Ident::new("option", Span::call_site()), 208 | arguments: syn::PathArguments::None, 209 | }, 210 | PathSegment { 211 | ident: Ident::new("Option", Span::call_site()), 212 | arguments: syn::PathArguments::AngleBracketed( 213 | syn::AngleBracketedGenericArguments { 214 | colon2_token: None, 215 | lt_token: Token![<](Span::call_site()), 216 | args: syn::punctuated::Punctuated::from_iter(vec![ 217 | syn::GenericArgument::Type(ty), 218 | ]), 219 | gt_token: Token![>](Span::call_site()), 220 | }, 221 | ), 222 | }, 223 | ]), 224 | }, 225 | }) 226 | } 227 | -------------------------------------------------------------------------------- /crates/config/src/sorting.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::Ordering, collections::HashMap, marker::PhantomData}; 2 | 3 | use serde::{de::Visitor, Deserialize}; 4 | 5 | use dbus::notification::Notification; 6 | 7 | #[derive(Debug, Default, Clone)] 8 | pub struct Sorting { 9 | by: SortBy, 10 | ordering: CmpOrdering, 11 | } 12 | 13 | impl Sorting { 14 | pub fn get_cmp(&self) -> for<'a, 'b> fn(&'a T, &'b T) -> Ordering 15 | where 16 | for<'a> &'a T: Into<&'a Notification>, 17 | { 18 | match &self.by { 19 | SortBy::Id => match &self.ordering { 20 | CmpOrdering::Ascending => Self::cmp_by_id, 21 | CmpOrdering::Descending => |lhs, rhs| Self::cmp_by_id(rhs, lhs), 22 | }, 23 | SortBy::Urgency => match &self.ordering { 24 | CmpOrdering::Ascending => Self::cmp_by_urgency, 25 | CmpOrdering::Descending => |lhs, rhs| Self::cmp_by_urgency(rhs, lhs), 26 | }, 27 | SortBy::Time => match &self.ordering { 28 | CmpOrdering::Ascending => Self::cmp_by_time, 29 | CmpOrdering::Descending => |lhs, rhs| Self::cmp_by_time(rhs, lhs), 30 | }, 31 | } 32 | } 33 | 34 | fn cmp_by_id(lhs: &T, rhs: &T) -> Ordering 35 | where 36 | for<'a> &'a T: Into<&'a Notification>, 37 | { 38 | Into::<&Notification>::into(lhs) 39 | .id 40 | .cmp(&Into::<&Notification>::into(rhs).id) 41 | } 42 | 43 | fn cmp_by_urgency(lhs: &T, rhs: &T) -> Ordering 44 | where 45 | for<'a> &'a T: Into<&'a Notification>, 46 | { 47 | Into::<&Notification>::into(lhs) 48 | .hints 49 | .urgency 50 | .cmp(&Into::<&Notification>::into(rhs).hints.urgency) 51 | } 52 | 53 | fn cmp_by_time(lhs: &T, rhs: &T) -> Ordering 54 | where 55 | for<'a> &'a T: Into<&'a Notification>, 56 | { 57 | Into::<&Notification>::into(lhs) 58 | .created_at 59 | .cmp(&Into::<&Notification>::into(rhs).created_at) 60 | } 61 | } 62 | 63 | #[derive(Debug, Default, Clone)] 64 | pub enum SortBy { 65 | Id, 66 | Urgency, 67 | #[default] 68 | Time, 69 | } 70 | 71 | impl SortBy { 72 | const POSSIBLE_VALUES: [&'static str; 4] = ["default", "id", "urgency", "time"]; 73 | } 74 | 75 | impl From<&String> for SortBy { 76 | fn from(value: &String) -> Self { 77 | match value.as_str() { 78 | "default" => Default::default(), 79 | "id" => SortBy::Id, 80 | "urgency" => SortBy::Urgency, 81 | "time" => SortBy::Time, 82 | _ => unreachable!(), 83 | } 84 | } 85 | } 86 | 87 | #[derive(Debug, Default, Clone)] 88 | pub enum CmpOrdering { 89 | #[default] 90 | Ascending, 91 | Descending, 92 | } 93 | 94 | impl CmpOrdering { 95 | const POSSIBLE_VALUES: [&'static str; 4] = ["ascending", "asc", "descending", "desc"]; 96 | } 97 | 98 | impl From<&String> for CmpOrdering { 99 | fn from(value: &String) -> Self { 100 | match value.as_str() { 101 | "ascending" | "asc" => CmpOrdering::Ascending, 102 | "descending" | "desc" => CmpOrdering::Descending, 103 | _ => unreachable!(), 104 | } 105 | } 106 | } 107 | 108 | impl From for Sorting { 109 | fn from(value: String) -> Self { 110 | match value.as_str() { 111 | "id" => Sorting { 112 | by: SortBy::Id, 113 | ..Default::default() 114 | }, 115 | "urgency" => Sorting { 116 | by: SortBy::Urgency, 117 | ..Default::default() 118 | }, 119 | _ => Default::default(), 120 | } 121 | } 122 | } 123 | 124 | impl From> for Sorting { 125 | fn from(map: HashMap) -> Self { 126 | Self { 127 | by: map 128 | .get("by") 129 | .map(|value| value.into()) 130 | .expect("Must be by key from deserializer!"), 131 | ordering: map 132 | .get("ordering") 133 | .map(|value| value.into()) 134 | .unwrap_or_default(), 135 | } 136 | } 137 | } 138 | 139 | struct SortingVisitor(PhantomData T>); 140 | 141 | impl<'de> Deserialize<'de> for Sorting { 142 | fn deserialize(deserializer: D) -> Result 143 | where 144 | D: serde::Deserializer<'de>, 145 | { 146 | deserializer.deserialize_any(SortingVisitor(PhantomData)) 147 | } 148 | } 149 | 150 | impl<'de, T> Visitor<'de> for SortingVisitor 151 | where 152 | T: Deserialize<'de> + From + From>, 153 | { 154 | type Value = T; 155 | 156 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 157 | let possible_values = SortBy::POSSIBLE_VALUES 158 | .iter() 159 | .fold(String::new(), |acc, val| { 160 | if acc.is_empty() { 161 | acc + "\"" + val + "\"" 162 | } else { 163 | acc + " | \"" + val + "\"" 164 | } 165 | }); 166 | write!( 167 | formatter, 168 | r#"either String or Table value. 169 | Example: 170 | 171 | sorting = "id" 172 | # or 173 | sorting = {{ by = "id" }} 174 | # or 175 | sorting = {{ by = "id", ordering = "descending" }} 176 | 177 | Possible values: 178 | sorting = {possible_values} 179 | 180 | sorting = {{ 181 | by: String = {possible_values} 182 | ordering: String? = "ascending" | "asc" | "descending" | "desc" 183 | }}"# 184 | ) 185 | } 186 | 187 | fn visit_str(self, v: &str) -> Result 188 | where 189 | E: serde::de::Error, 190 | { 191 | if SortBy::POSSIBLE_VALUES.contains(&v) { 192 | Ok(v.to_owned().into()) 193 | } else { 194 | Err(serde::de::Error::invalid_value( 195 | serde::de::Unexpected::Str(v), 196 | &self, 197 | )) 198 | } 199 | } 200 | 201 | fn visit_string(self, v: String) -> Result 202 | where 203 | E: serde::de::Error, 204 | { 205 | if SortBy::POSSIBLE_VALUES.contains(&v.as_str()) { 206 | Ok(v.into()) 207 | } else { 208 | Err(serde::de::Error::invalid_value( 209 | serde::de::Unexpected::Str(&v), 210 | &self, 211 | )) 212 | } 213 | } 214 | 215 | fn visit_map(self, mut map: A) -> Result 216 | where 217 | A: serde::de::MapAccess<'de>, 218 | { 219 | let mut local_map = HashMap::new(); 220 | 221 | while let Some((key, value)) = map.next_entry::()? { 222 | match key.as_str() { 223 | "by" => { 224 | if SortBy::POSSIBLE_VALUES.contains(&value.as_str()) { 225 | local_map.insert(key, value); 226 | } else { 227 | return Err(serde::de::Error::invalid_value( 228 | serde::de::Unexpected::Str(&value), 229 | &self, 230 | )); 231 | } 232 | } 233 | "ordering" => { 234 | if CmpOrdering::POSSIBLE_VALUES.contains(&value.as_str()) { 235 | local_map.insert(key, value); 236 | } else { 237 | return Err(serde::de::Error::invalid_value( 238 | serde::de::Unexpected::Str(&value), 239 | &self, 240 | )); 241 | } 242 | } 243 | _ => return Err(serde::de::Error::unknown_variant(&key, &["by", "ordering"])), 244 | } 245 | } 246 | 247 | if !local_map.contains_key("by") { 248 | return Err(serde::de::Error::missing_field("by")); 249 | } 250 | 251 | Ok(local_map.into()) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /crates/macros/README.md: -------------------------------------------------------------------------------- 1 | # Noti macro 2 | 3 | The crate that provides macros to `noti` application for various cases. 4 | Currently available only two macro: 5 | 6 | - ConfigProperty 7 | - GenericBuilder 8 | 9 | Below I described for what purposes these macro were written and how use them 10 | in `noti` application code. 11 | 12 | ## ConfigProperty 13 | 14 | During developing the `noti` application the `Noti` team found that increasing 15 | config variables attracts high code complexity. And this can easily be casue of 16 | leading to typic bugs due careless. 17 | 18 | To avoid this, `Noti` team decided to write a macro that do dirty job and provides 19 | simple and useful function which can help with config variables mess. 20 | 21 | The `ConfigProperty` is powerful, allowing having temporary config variables, 22 | inherit them into another variable, use correct type, set default values and 23 | add the way to merge. Here below we listed possible actions with a bit explanation: 24 | 25 | - `also_from` - adds another temporary field from which the value can be acheived. 26 | It can be helpful if you want to provide another way to set value for several fields 27 | like temporary 'text' property for 'summary' and 'body' together. 28 | 29 | - `mergeable` - marks that field can be merged with the same field from other 30 | instance of the same structure. 31 | 32 | - `default` - marks that field can use default value by Default trait or use other ways set. 33 | 34 | - And the last, `use_type`. It was added because of the way by which macro works. 35 | 36 | To use them, you should just put into attribute `cfg_prop` keywords that described 37 | above (e.g. `#[cfg_prop(also_from(name = field_name), default)]`). Here is the format of 38 | attribute for each action: 39 | 40 | - `#[cfg_prop(also_from(name = field_name))]` where `field_name` should be distinct 41 | from existing field names. And the field type should be same if you want set the same 42 | `field_name` for some fields. Also you can enable `mergeable` option for this field, 43 | like `source_field.merge(field_name)`, using the addition: 44 | `#[cfg_prop(also_from(name = field_name, mergeable))]` 45 | 46 | - `#[cfg_prop(mergeable)]` 47 | 48 | - For `default` there are 3 ways to do it: 49 | 50 | - `#[cfg_prop(default)]` - uses `Default` trait. 51 | - `#[cfg_prop(default(path = path::to::function))]` - uses path to function (and 52 | don't call the function). 53 | - `#[cfg_prop(default(expression))]` where expression is any expression that you 54 | can write. Useful when need to set simple value like `400` for integer or 55 | `"Hell".to_string()` for String type. 56 | 57 | - `#[cfg_prop(use_type(SpecificType))]` where `SpecificType` should be valid and 58 | have the trait `From for SpecificType`. 59 | 60 | > [!NOTE] 61 | > The ConfigProperty macro will try to use default values, even if you didn't attach the 62 | > `#[cfg_prop(default)]` attribute. It is because need to unwrap `Option` types by 63 | > default values if there is no stored value. 64 | 65 | ### Usage 66 | 67 | Choose or write a struct to which you want to attach the `ConfigProperty` macro. 68 | This struct shuold be as clear as possible. If it can be default value instead of 69 | `Option` type, use it. Let's lookup an example below: 70 | 71 | ```rust 72 | #[derive(ConfigProperty)] 73 | #[cfg_prop(name(DirtyConfig))] 74 | struct Config { 75 | #[cfg_prop(also_from(name = temporary_value), default)] 76 | helpful_value: String, 77 | 78 | // temporary_value: Option will be created in new struct 79 | 80 | #[cfg_prop(use_type(DirtySubconfig), mergeable)] 81 | complex_value: Subconfig, 82 | } 83 | 84 | #[derive(ConfigProperty)] 85 | #[cfg_prop(name(DirtySubconfig))] 86 | struct Subconfig { 87 | #[cfg_prop(default(path = crate::path::to::default_simple_value))] 88 | simple_value: i32, 89 | 90 | #[cfg_prop(default(EnumType::Variant))] 91 | enum_value: EnumType 92 | } 93 | 94 | // This function should be reachable 95 | fn default_simple_value() -> i32 { 10 } 96 | ``` 97 | 98 | As you can see there are a lot of thing which are associated to `ConfigProperty` 99 | macro. Firstly, you see `cfg_prop` macro attribute that uses to define properties. 100 | The `#[cfg_prop(name(StructName))]` is important and you always should to set it. 101 | It will produce new struct which have a big differences. 102 | 103 | For better understanding imagine that new created structs are mirrored to original 104 | structs but with a lot of boilerplate code. And these new structs are more convenient 105 | to use for deserializing data that can be in various form and it should be sustainable. 106 | For new struct creates `impl`s: 107 | 108 | - `fn merge(self, other: Option) -> Self` - merges the current structure with 109 | other structures by filling empty config values. It's very helpful if provides the 110 | same structure but from various sources, and need to fill up with secondary or 111 | default values. 112 | 113 | - `fn unwrap_or_default(self) -> OriginalType` where `OriginalType` is original type 114 | to which the derive macro was attached. Converts the derived struct which contains 115 | field types that wrapped by `Option` to original type by unwrapping field values 116 | and filling default values into them. 117 | 118 | Also creates the single `From for OriginalStruct` trait in which just 119 | calls `unwrap_or_default` method that described above. 120 | 121 | ## GenericBuilder 122 | 123 | The `Noti` team figured out that need to use something similar to reflection in 124 | Rust but the programming language doesn't provides ways to do this. So instead 125 | of changing existing struct we decided create a builder that provides way to 126 | set values to fields by string. It's very helpful for future parsing and analyzing 127 | where need to build a bunch of structs from plain text. 128 | 129 | By description of issue the `GenericBuilder` derive macro was written (below we'll 130 | call GBuilder instead of GenericBuilder for brevity). Currently this macro have 3 131 | attributes: 132 | 133 | For struct: 134 | 135 | - `#[gbuilder(name(GBuilderStruct))]` - it's neccessary attribute from which the macro 136 | can get name for the new builder struct (in this example it will be `GBuilderStruct`). 137 | Places only before struct definintion. 138 | 139 | For fields: 140 | 141 | - `#[gbuilder(hidden)]` - hides the fields from users but still uses for building new 142 | struct. For fields which have `hidden` attribute the methods `contains_field` and 143 | `set_value` will return `false` and error respectively. Usually sets with default 144 | attribute. 145 | 146 | - There are three ways to set default value and it's same as `#[cfg_prop(default)]` 147 | that described in [ConfigProperty](#configproperty) section: 148 | - `#[gbuilder(default)]` - tries to use Default trait. 149 | - `#[gbuilder(default(path = path::to::function))]` - calls the function by provided path 150 | (don't call the function!). 151 | - `#[gbuilder(default(expression))]` - evaluates the provided expression. 152 | 153 | The generated GBuilder struct will have 4 methods: 154 | 155 | - `GBuilder::new()` - creates new GBuilder instance. 156 | - `GBuilder::contains_field(&self, field_name: &str) -> bool` - checks whether contains 157 | finding field or not. 158 | - `GBuilder::set_value(&mut self, field_name: &str, value: Value) -> Result<(), Box>` - 159 | tries to set value for mentioned field. 160 | - `GBuilder::try_build(self) -> Result` - tries to build an OriginStruct 161 | at which was attached macro of this builder. 162 | 163 | At this moment we should tell you that the `Value` type you should implement by yourself. 164 | And this type must implement `TryFrom` trait for unhidden field types. You won't have any 165 | issue when you still use primitive types like integer or `String`. But when you reach the 166 | state when need to implement `TryFrom` for various custom types, you'll understand that 167 | there is a big issue with flexibility. So here you can use `std::any::Any`. Especially if 168 | you use this builder not so frequently and it will be ok. 169 | 170 | The example of `Value` type you can see in tests. 171 | -------------------------------------------------------------------------------- /crates/config/src/display.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, marker::PhantomData, path::PathBuf}; 2 | 3 | use dbus::notification::Urgency; 4 | use macros::{ConfigProperty, GenericBuilder}; 5 | use serde::{de::Visitor, Deserialize}; 6 | use shared::{error::ConversionError, value::TryFromValue}; 7 | 8 | use crate::{ 9 | public, 10 | spacing::Spacing, 11 | text::{TextProperty, TomlTextProperty}, 12 | }; 13 | 14 | public! { 15 | #[derive(ConfigProperty, Debug)] 16 | #[cfg_prop(name(TomlDisplayConfig), derive(Debug, Deserialize, Default, Clone))] 17 | struct DisplayConfig { 18 | layout: Layout, 19 | 20 | theme: String, 21 | 22 | #[cfg_prop(use_type(IconInfoProperty), mergeable)] 23 | icons: IconInfo, 24 | 25 | #[cfg_prop(use_type(TomlImageProperty), mergeable)] 26 | image: ImageProperty, 27 | 28 | padding: Spacing, 29 | 30 | #[cfg_prop(use_type(TomlBorder), mergeable)] 31 | border: Border, 32 | 33 | #[cfg_prop( 34 | also_from(name = text, mergeable), 35 | use_type(TomlTextProperty), 36 | default(TomlTextProperty::default_summary()), 37 | mergeable 38 | )] 39 | summary: TextProperty, 40 | 41 | #[cfg_prop( 42 | also_from(name = text, mergeable), 43 | use_type(TomlTextProperty), 44 | mergeable 45 | )] 46 | body: TextProperty, 47 | 48 | #[cfg_prop(default(true))] 49 | markup: bool, 50 | 51 | #[cfg_prop(default(Timeout::new(0)))] 52 | timeout: Timeout, 53 | } 54 | } 55 | 56 | impl TomlDisplayConfig { 57 | pub(super) fn use_relative_path(&mut self, mut prefix: PathBuf) { 58 | if let Some(Layout::FromPath { ref mut path_buf }) = self.layout.as_mut() { 59 | if path_buf.is_relative() { 60 | prefix.extend(&*path_buf); 61 | *path_buf = prefix; 62 | } 63 | }; 64 | } 65 | } 66 | 67 | #[derive(Deserialize, Debug, Default, Clone)] 68 | #[serde(from = "String")] 69 | pub enum Layout { 70 | #[default] 71 | Default, 72 | FromPath { 73 | path_buf: PathBuf, 74 | }, 75 | } 76 | 77 | impl Layout { 78 | pub fn is_default(&self) -> bool { 79 | matches!(self, Layout::Default) 80 | } 81 | } 82 | 83 | impl From for Layout { 84 | fn from(value: String) -> Self { 85 | if value == "default" { 86 | return Layout::Default; 87 | } 88 | 89 | Layout::FromPath { 90 | path_buf: PathBuf::from( 91 | shellexpand::full(&value) 92 | .map(|value| value.into_owned()) 93 | .unwrap_or(value), 94 | ), 95 | } 96 | } 97 | } 98 | 99 | public! { 100 | #[derive(ConfigProperty, Debug)] 101 | #[cfg_prop(name(IconInfoProperty), derive(Debug, Deserialize, Clone, Default))] 102 | struct IconInfo { 103 | #[cfg_prop(default("Adwaita".to_string()))] 104 | theme: String, 105 | 106 | #[cfg_prop(default(vec![64, 32]))] 107 | size: Vec, 108 | } 109 | } 110 | 111 | public! { 112 | #[derive(ConfigProperty, GenericBuilder, Debug, Clone)] 113 | #[cfg_prop(name(TomlImageProperty), derive(Debug, Clone, Default, Deserialize))] 114 | #[gbuilder(name(GBuilderImageProperty), derive(Clone))] 115 | struct ImageProperty { 116 | #[cfg_prop(default(64))] 117 | #[gbuilder(default(64))] 118 | max_size: u16, 119 | 120 | #[cfg_prop(default(0))] 121 | #[gbuilder(default(0))] 122 | rounding: u16, 123 | 124 | #[gbuilder(default)] 125 | margin: Spacing, 126 | 127 | #[gbuilder(default)] 128 | resizing_method: ResizingMethod, 129 | } 130 | } 131 | 132 | impl Default for ImageProperty { 133 | fn default() -> Self { 134 | TomlImageProperty::default().into() 135 | } 136 | } 137 | 138 | impl TryFromValue for ImageProperty {} 139 | 140 | #[derive(Debug, Deserialize, Default, Clone)] 141 | pub enum ResizingMethod { 142 | #[serde(rename = "nearest")] 143 | Nearest, 144 | #[serde(rename = "triangle")] 145 | Triangle, 146 | #[serde(rename = "catmull-rom")] 147 | CatmullRom, 148 | #[default] 149 | #[serde(rename = "gaussian")] 150 | Gaussian, 151 | #[serde(rename = "lanczos3")] 152 | Lanczos3, 153 | } 154 | 155 | impl TryFromValue for ResizingMethod { 156 | fn try_from_string(value: String) -> Result { 157 | Ok(match value.to_lowercase().as_str() { 158 | "nearest" => ResizingMethod::Nearest, 159 | "triangle" => ResizingMethod::Triangle, 160 | "catmull-rom" | "catmull_rom" => ResizingMethod::CatmullRom, 161 | "gaussian" => ResizingMethod::Gaussian, 162 | "lanczos3" => ResizingMethod::Lanczos3, 163 | _ => Err(shared::error::ConversionError::InvalidValue { 164 | expected: "nearest, triangle, gaussian, lanczos3, catmull-rom or catmull_rom", 165 | actual: value, 166 | })?, 167 | }) 168 | } 169 | } 170 | 171 | public! { 172 | #[derive(ConfigProperty, GenericBuilder, Debug, Default, Clone)] 173 | #[cfg_prop(name(TomlBorder), derive(Debug, Clone, Default, Deserialize))] 174 | #[gbuilder(name(GBuilderBorder), derive(Clone))] 175 | struct Border { 176 | #[cfg_prop(default(0))] 177 | #[gbuilder(default(0))] 178 | size: u8, 179 | 180 | #[cfg_prop(default(0))] 181 | #[gbuilder(default(0))] 182 | radius: u8, 183 | } 184 | } 185 | 186 | impl TryFromValue for Border {} 187 | 188 | #[derive(Debug, Default, Clone)] 189 | pub struct Timeout { 190 | default: Option, 191 | low: Option, 192 | normal: Option, 193 | critical: Option, 194 | } 195 | 196 | impl Timeout { 197 | const DEFAULT: u16 = 0; 198 | 199 | fn new(default_value: u16) -> Self { 200 | Self { 201 | default: default_value.into(), 202 | ..Default::default() 203 | } 204 | } 205 | 206 | pub fn by_urgency(&self, urgency: &Urgency) -> u16 { 207 | match urgency { 208 | Urgency::Low => self.low, 209 | Urgency::Normal => self.normal, 210 | Urgency::Critical => self.critical, 211 | } 212 | .or(self.default) 213 | .unwrap_or(Self::DEFAULT) 214 | } 215 | } 216 | 217 | impl From for Timeout { 218 | fn from(value: u16) -> Self { 219 | Timeout::new(value) 220 | } 221 | } 222 | 223 | impl From> for Timeout { 224 | fn from(value: HashMap) -> Self { 225 | Timeout { 226 | default: value.get("default").copied(), 227 | low: value.get("low").copied(), 228 | normal: value.get("normal").copied(), 229 | critical: value.get("critical").copied(), 230 | } 231 | } 232 | } 233 | 234 | struct TimeoutVisitor(PhantomData T>); 235 | 236 | impl<'de> Deserialize<'de> for Timeout { 237 | fn deserialize(deserializer: D) -> Result 238 | where 239 | D: serde::Deserializer<'de>, 240 | { 241 | deserializer.deserialize_any(TimeoutVisitor(PhantomData)) 242 | } 243 | } 244 | 245 | impl<'de, T> Visitor<'de> for TimeoutVisitor 246 | where 247 | T: Deserialize<'de> + From + From>, 248 | { 249 | type Value = T; 250 | 251 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 252 | write!( 253 | formatter, 254 | r#"Either u16 or Table value. 255 | 256 | Example: 257 | 258 | # In milliseconds 259 | display.timeout = 2000 260 | 261 | # or 262 | 263 | [display.timeout] 264 | low = 2000 265 | normal = 4000 266 | critical = 5000 267 | 268 | # or 269 | 270 | [display.timeout] 271 | default = 3000 # for low and normal this value will be set 272 | critical = 0 # but for critical the default value will be overriden 273 | "# 274 | ) 275 | } 276 | 277 | fn visit_u16(self, v: u16) -> Result 278 | where 279 | E: serde::de::Error, 280 | { 281 | Ok(v.into()) 282 | } 283 | 284 | fn visit_i64(self, v: i64) -> Result 285 | where 286 | E: serde::de::Error, 287 | { 288 | if v < 0 { 289 | Err(serde::de::Error::invalid_type( 290 | serde::de::Unexpected::Signed(v), 291 | &self, 292 | )) 293 | } else { 294 | Ok((v.clamp(0, u16::MAX as i64) as u16).into()) 295 | } 296 | } 297 | 298 | fn visit_map(self, mut map: A) -> Result 299 | where 300 | A: serde::de::MapAccess<'de>, 301 | { 302 | let mut local_map = HashMap::new(); 303 | 304 | while let Some((key, value)) = map.next_entry::()? { 305 | match key.as_str() { 306 | "default" | "low" | "normal" | "critical" => { 307 | local_map.insert(key, value); 308 | } 309 | _ => { 310 | return Err(serde::de::Error::unknown_variant( 311 | &key, 312 | &["default", "low", "normal", "critical"], 313 | )) 314 | } 315 | } 316 | } 317 | 318 | Ok(local_map.into()) 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /crates/config/src/spacing.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | marker::PhantomData, 4 | ops::{Add, AddAssign}, 5 | }; 6 | 7 | use macros::GenericBuilder; 8 | use serde::{de::Visitor, Deserialize}; 9 | use shared::value::TryFromValue; 10 | 11 | #[derive(GenericBuilder, Debug, Default, Clone)] 12 | #[gbuilder(name(GBuilderSpacing), derive(Clone), constructor)] 13 | pub struct Spacing { 14 | #[gbuilder(default(0), aliases(vertical, all))] 15 | top: u8, 16 | 17 | #[gbuilder(default(0), aliases(horizontal, all))] 18 | right: u8, 19 | 20 | #[gbuilder(default(0), aliases(vertical, all))] 21 | bottom: u8, 22 | 23 | #[gbuilder(default(0), aliases(horizontal, all))] 24 | left: u8, 25 | } 26 | 27 | impl TryFromValue for Spacing {} 28 | 29 | impl Spacing { 30 | const POSSIBLE_KEYS: [&'static str; 6] = 31 | ["top", "right", "bottom", "left", "vertical", "horizontal"]; 32 | 33 | pub fn all_directional(val: u8) -> Self { 34 | Self { 35 | top: val, 36 | bottom: val, 37 | right: val, 38 | left: val, 39 | } 40 | } 41 | 42 | pub fn cross(vertical: u8, horizontal: u8) -> Self { 43 | Self { 44 | top: vertical, 45 | bottom: vertical, 46 | right: horizontal, 47 | left: horizontal, 48 | } 49 | } 50 | 51 | pub fn top(&self) -> u8 { 52 | self.top 53 | } 54 | 55 | pub fn set_top(&mut self, top: u8) { 56 | self.top = top; 57 | } 58 | 59 | pub fn right(&self) -> u8 { 60 | self.right 61 | } 62 | 63 | pub fn set_right(&mut self, right: u8) { 64 | self.right = right; 65 | } 66 | 67 | pub fn bottom(&self) -> u8 { 68 | self.bottom 69 | } 70 | 71 | pub fn set_bottom(&mut self, bottom: u8) { 72 | self.bottom = bottom; 73 | } 74 | 75 | pub fn left(&self) -> u8 { 76 | self.left 77 | } 78 | 79 | pub fn set_left(&mut self, left: u8) { 80 | self.left = left; 81 | } 82 | 83 | pub fn horizontal(&self) -> u16 { 84 | self.left as u16 + self.right as u16 85 | } 86 | 87 | pub fn vertical(&self) -> u16 { 88 | self.top as u16 + self.bottom as u16 89 | } 90 | 91 | pub fn shrink(&self, width: &mut usize, height: &mut usize) { 92 | *width = width.saturating_sub(self.left as usize + self.right as usize); 93 | *height = height.saturating_sub(self.top as usize + self.bottom as usize); 94 | } 95 | } 96 | 97 | impl Add for Spacing { 98 | type Output = Spacing; 99 | 100 | fn add(self, rhs: Spacing) -> Self::Output { 101 | Spacing { 102 | top: self.top + rhs.top, 103 | right: self.right + rhs.right, 104 | bottom: self.bottom + rhs.bottom, 105 | left: self.left + rhs.left, 106 | } 107 | } 108 | } 109 | 110 | impl Add for &Spacing { 111 | type Output = Spacing; 112 | 113 | fn add(self, rhs: Spacing) -> Self::Output { 114 | Spacing { 115 | top: self.top + rhs.top, 116 | right: self.right + rhs.right, 117 | bottom: self.bottom + rhs.bottom, 118 | left: self.left + rhs.left, 119 | } 120 | } 121 | } 122 | 123 | impl AddAssign for Spacing { 124 | fn add_assign(&mut self, rhs: Spacing) { 125 | self.top += rhs.top; 126 | self.right += rhs.right; 127 | self.bottom += rhs.bottom; 128 | self.left += rhs.left; 129 | } 130 | } 131 | impl From for Spacing { 132 | fn from(value: i64) -> Self { 133 | Spacing::all_directional(value.clamp(0, u8::MAX as i64) as u8) 134 | } 135 | } 136 | 137 | impl From> for Spacing { 138 | fn from(value: Vec) -> Self { 139 | match value.len() { 140 | 1 => Spacing::all_directional(value[0]), 141 | 2 => Spacing::cross(value[0], value[1]), 142 | 3 => Spacing { 143 | top: value[0], 144 | right: value[1], 145 | left: value[1], 146 | bottom: value[2], 147 | }, 148 | 4 => Spacing { 149 | top: value[0], 150 | right: value[1], 151 | bottom: value[2], 152 | left: value[3], 153 | }, 154 | _ => unreachable!(), 155 | } 156 | } 157 | } 158 | 159 | impl From> for Spacing { 160 | fn from(map: HashMap) -> Self { 161 | let vertical = map.get("vertical"); 162 | let horizontal = map.get("horizontal"); 163 | let top = map.get("top"); 164 | let bottom = map.get("bottom"); 165 | let right = map.get("right"); 166 | let left = map.get("left"); 167 | 168 | Self { 169 | top: *top.or(vertical).unwrap_or(&0), 170 | bottom: *bottom.or(vertical).unwrap_or(&0), 171 | right: *right.or(horizontal).unwrap_or(&0), 172 | left: *left.or(horizontal).unwrap_or(&0), 173 | } 174 | } 175 | } 176 | 177 | impl<'de> Deserialize<'de> for Spacing { 178 | fn deserialize(deserializer: D) -> Result 179 | where 180 | D: serde::Deserializer<'de>, 181 | { 182 | deserializer.deserialize_any(PaddingVisitor(PhantomData)) 183 | } 184 | } 185 | 186 | struct PaddingVisitor(PhantomData T>); 187 | 188 | impl<'de, T> Visitor<'de> for PaddingVisitor 189 | where 190 | T: Deserialize<'de> + From> + From> + From, 191 | { 192 | type Value = T; 193 | 194 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 195 | write!( 196 | formatter, 197 | r#"either u8, [u8, u8], [u8, u8, u8], [u8, u8, u8, u8] or Table. 198 | 199 | Example: 200 | 201 | # All-directional margin 202 | margin = 3 203 | 204 | # The application can also apply the CSS-like values: 205 | # Applies vertical and horizontal paddings respectively 206 | padding = [0, 5] 207 | 208 | # Applies top, horizontal and bottom paddings respectively 209 | margin = [3, 2, 5] 210 | 211 | # Applies top, right, bottom, left paddings respectively 212 | padding = [1, 2, 3, 4] 213 | 214 | # When you want to declare in explicit way: 215 | 216 | # Sets only top padding 217 | padding = {{ top = 3 }} 218 | 219 | # Sets only top and right padding 220 | padding = {{ top = 5, right = 6 }} 221 | 222 | # Insead of 223 | # padding = {{ top = 5, right = 6, bottom = 5 }} 224 | # Write 225 | padding = {{ vertical = 5, right = 6 }} 226 | 227 | # If gots collision of values the error will throws because of ambuguity 228 | # padding = {{ top = 5, vertical = 6 }} 229 | 230 | # You can apply the same way for margin 231 | margin = {{ top = 5, horizontal = 10 }}"# 232 | ) 233 | } 234 | 235 | fn visit_i64(self, v: i64) -> Result 236 | where 237 | E: serde::de::Error, 238 | { 239 | Ok(v.into()) 240 | } 241 | 242 | fn visit_seq(self, mut seq: A) -> Result 243 | where 244 | A: serde::de::SeqAccess<'de>, 245 | { 246 | let mut fields = vec![]; 247 | while let Some(value) = seq.next_element::()? { 248 | fields.push(value); 249 | } 250 | 251 | match fields.len() { 252 | 1..=4 => Ok(fields.into()), 253 | other => Err(serde::de::Error::invalid_length(other, &self)), 254 | } 255 | } 256 | 257 | fn visit_map(self, mut map: A) -> Result 258 | where 259 | A: serde::de::MapAccess<'de>, 260 | { 261 | let mut custom_padding = HashMap::new(); 262 | 263 | while let Some((key, value)) = map.next_entry::()? { 264 | if !Spacing::POSSIBLE_KEYS.contains(&key.as_str()) { 265 | return Err(serde::de::Error::invalid_value( 266 | serde::de::Unexpected::Str(key.as_str()), 267 | &self, 268 | )); 269 | } 270 | 271 | match key.as_str() { 272 | "top" | "bottom" if custom_padding.contains_key("vertical") => { 273 | return Err(serde::de::Error::invalid_value( 274 | serde::de::Unexpected::Str(key.as_str()), 275 | &self, 276 | )) 277 | } 278 | "vertical" 279 | if custom_padding.contains_key("top") 280 | || custom_padding.contains_key("bottom") => 281 | { 282 | return Err(serde::de::Error::invalid_value( 283 | serde::de::Unexpected::Str(key.as_str()), 284 | &self, 285 | )) 286 | } 287 | "right" | "left" if custom_padding.contains_key("horizontal") => { 288 | return Err(serde::de::Error::invalid_value( 289 | serde::de::Unexpected::Str(key.as_str()), 290 | &self, 291 | )) 292 | } 293 | "horizontal" 294 | if custom_padding.contains_key("right") 295 | || custom_padding.contains_key("left") => 296 | { 297 | return Err(serde::de::Error::invalid_value( 298 | serde::de::Unexpected::Str(key.as_str()), 299 | &self, 300 | )) 301 | } 302 | _ => (), 303 | } 304 | 305 | custom_padding.insert(key, value); 306 | } 307 | 308 | if !custom_padding.is_empty() { 309 | Ok(custom_padding.into()) 310 | } else { 311 | Err(serde::de::Error::invalid_length(0, &self)) 312 | } 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /crates/backend/src/managers/window_manager/buffer.rs: -------------------------------------------------------------------------------- 1 | use log::{debug, error}; 2 | use render::types::RectSize; 3 | use std::{ 4 | collections::HashMap, 5 | fs::File, 6 | hash::Hash, 7 | os::{ 8 | fd::{AsFd, BorrowedFd}, 9 | unix::fs::FileExt, 10 | }, 11 | }; 12 | use wayland_client::{ 13 | delegate_noop, 14 | protocol::{wl_buffer::WlBuffer, wl_shm::WlShm, wl_shm_pool::WlShmPool}, 15 | Connection, Dispatch, EventQueue, 16 | }; 17 | 18 | use crate::dispatcher::Dispatcher; 19 | 20 | /// The dual-buffer for storing and using for surface. It will help manage buffers, when one uses 21 | /// in surface, the other must be used for writing data and flit them. 22 | pub(super) struct DualSlotedBuffer { 23 | buffers: [OwnedSlotedBuffer; 2], 24 | current: usize, 25 | } 26 | 27 | impl DualSlotedBuffer { 28 | pub(super) fn init

( 29 | protocols: &P, 30 | wayland_connection: &Connection, 31 | rect_size: &RectSize, 32 | ) -> Self 33 | where 34 | P: AsRef, 35 | { 36 | Self { 37 | buffers: [ 38 | OwnedSlotedBuffer::init(protocols, wayland_connection, rect_size), 39 | OwnedSlotedBuffer::init(protocols, wayland_connection, rect_size), 40 | ], 41 | current: 0, 42 | } 43 | } 44 | 45 | pub(super) fn flip(&mut self) { 46 | self.current = 1 - self.current; 47 | } 48 | 49 | pub(super) fn current(&self) -> &OwnedSlotedBuffer { 50 | &self.buffers[self.current] 51 | } 52 | 53 | pub(super) fn current_mut(&mut self) -> &mut OwnedSlotedBuffer { 54 | &mut self.buffers[self.current] 55 | } 56 | 57 | pub(super) fn other(&self) -> &OwnedSlotedBuffer { 58 | &self.buffers[1 - self.current] 59 | } 60 | } 61 | 62 | impl Dispatcher for DualSlotedBuffer { 63 | type State = BufferState; 64 | 65 | fn get_event_queue_and_state( 66 | &mut self, 67 | ) -> Option<(&mut EventQueue, &mut Self::State)> { 68 | None 69 | } 70 | 71 | fn dispatch(&mut self) -> anyhow::Result { 72 | let mut is_dispatched = false; 73 | 74 | for buffer in &mut self.buffers { 75 | is_dispatched |= buffer.dispatch()?; 76 | } 77 | 78 | Ok(is_dispatched) 79 | } 80 | } 81 | 82 | /// The owner of sloted buffer in wayland. It handles the drop and the data will be released. 83 | pub(super) struct OwnedSlotedBuffer { 84 | event_queue: EventQueue, 85 | state: BufferState, 86 | sloted_buffer: SlotedBuffer, 87 | } 88 | 89 | pub(super) struct BufferState { 90 | wl_shm_pool: WlShmPool, 91 | wl_buffer: WlBuffer, 92 | busy: bool, 93 | } 94 | 95 | impl OwnedSlotedBuffer { 96 | fn init

(protocols: &P, wayland_connection: &Connection, rect_size: &RectSize) -> Self 97 | where 98 | P: AsRef, 99 | { 100 | let event_queue = wayland_connection.new_event_queue(); 101 | 102 | let size = rect_size.area() * 4; 103 | let mut sloted_buffer = SlotedBuffer::new(); 104 | sloted_buffer.push_without_slot(&vec![0; size]); 105 | 106 | let wl_shm_pool =

>::as_ref(protocols).create_pool( 107 | sloted_buffer.buffer.as_fd(), 108 | size as i32, 109 | &event_queue.handle(), 110 | (), 111 | ); 112 | 113 | let wl_buffer = wl_shm_pool.create_buffer( 114 | 0, 115 | rect_size.width as i32, 116 | rect_size.height as i32, 117 | rect_size.width as i32 * 4, 118 | wayland_client::protocol::wl_shm::Format::Argb8888, 119 | &event_queue.handle(), 120 | (), 121 | ); 122 | 123 | let state = BufferState { 124 | wl_shm_pool, 125 | wl_buffer, 126 | busy: true, 127 | }; 128 | 129 | Self { 130 | event_queue, 131 | state, 132 | sloted_buffer, 133 | } 134 | } 135 | 136 | pub(super) fn build(&mut self, rect_size: &RectSize) { 137 | assert!( 138 | self.sloted_buffer.buffer.size() >= rect_size.area() * 4, 139 | "Buffer size must be greater or equal to window size. Buffer size: {}. Window area: {}.", 140 | self.sloted_buffer.buffer.size(), 141 | rect_size.area() * 4 142 | ); 143 | 144 | //INFO: The Buffer size only growth and it guarantee that shm_pool never shrinks 145 | self.state 146 | .wl_shm_pool 147 | .resize(self.sloted_buffer.buffer.size() as i32); 148 | 149 | self.state.wl_buffer.destroy(); 150 | self.state.wl_buffer = self.state.wl_shm_pool.create_buffer( 151 | 0, 152 | rect_size.width as i32, 153 | rect_size.height as i32, 154 | rect_size.width as i32 * 4, 155 | wayland_client::protocol::wl_shm::Format::Argb8888, 156 | &self.event_queue.handle(), 157 | (), 158 | ); 159 | } 160 | 161 | pub(super) fn wl_buffer(&self) -> &WlBuffer { 162 | &self.state.wl_buffer 163 | } 164 | } 165 | 166 | impl Drop for OwnedSlotedBuffer { 167 | fn drop(&mut self) { 168 | self.state.wl_buffer.destroy(); 169 | self.state.wl_shm_pool.destroy(); 170 | 171 | if let Err(_err) = self.event_queue.roundtrip(&mut self.state) { 172 | error!("OwnedSlotedBuffer: Failed to sync during deinitialization.") 173 | }; 174 | } 175 | } 176 | 177 | impl std::ops::Deref for OwnedSlotedBuffer { 178 | type Target = SlotedBuffer; 179 | 180 | fn deref(&self) -> &Self::Target { 181 | &self.sloted_buffer 182 | } 183 | } 184 | 185 | impl std::ops::DerefMut for OwnedSlotedBuffer { 186 | fn deref_mut(&mut self) -> &mut Self::Target { 187 | &mut self.sloted_buffer 188 | } 189 | } 190 | 191 | impl Dispatcher for OwnedSlotedBuffer { 192 | type State = BufferState; 193 | 194 | fn get_event_queue_and_state( 195 | &mut self, 196 | ) -> Option<(&mut EventQueue, &mut Self::State)> { 197 | Some((&mut self.event_queue, &mut self.state)) 198 | } 199 | } 200 | 201 | delegate_noop!(BufferState: ignore WlShmPool); 202 | 203 | impl Dispatch for BufferState { 204 | fn event( 205 | state: &mut Self, 206 | _proxy: &WlBuffer, 207 | event: ::Event, 208 | _data: &(), 209 | _conn: &Connection, 210 | _qhandle: &wayland_client::QueueHandle, 211 | ) { 212 | if let wayland_client::protocol::wl_buffer::Event::Release = event { 213 | state.busy = false 214 | } 215 | } 216 | } 217 | 218 | /// The wrapper for Buffer which can deal with Slots. It allows to store data by key for reading 219 | /// later. Also SlotedBuffer allows write data without key if it's useless to read later. 220 | pub(super) struct SlotedBuffer { 221 | pub(super) buffer: Buffer, 222 | slots: HashMap, 223 | } 224 | 225 | struct Slot { 226 | offset: usize, 227 | len: usize, 228 | } 229 | 230 | impl SlotedBuffer { 231 | pub(super) fn new() -> Self { 232 | debug!("SlotedBuffer: Trying to create"); 233 | let sb = Self { 234 | buffer: Buffer::new(), 235 | slots: HashMap::new(), 236 | }; 237 | debug!("SlotedBuffer: Created!"); 238 | sb 239 | } 240 | 241 | /// Clears the data of buffers and slots. But the size of buffer remains. 242 | pub(super) fn reset(&mut self) { 243 | self.buffer.reset(); 244 | self.slots.clear(); 245 | debug!("SlotedBuffer: Reset") 246 | } 247 | 248 | /// Pushes the data with creating a slot into buffer. 249 | pub(super) fn push(&mut self, key: T, data: &[u8]) 250 | where 251 | T: Hash + Eq, 252 | { 253 | self.slots.insert( 254 | key, 255 | Slot { 256 | offset: self.buffer.filled_size(), 257 | len: data.len(), 258 | }, 259 | ); 260 | self.buffer.push(data); 261 | debug!("SlotedBuffer: Received data to create Slot") 262 | } 263 | 264 | /// Pushes the data wihtout creating a slot into buffer. 265 | pub(super) fn push_without_slot(&mut self, data: &[u8]) { 266 | self.buffer.push(data); 267 | debug!("SlotedBuffer: Received data to write without slot.") 268 | } 269 | 270 | /// Retrieves a copy of data using specific slot by key. 271 | pub(super) fn data_by_slot(&self, key: &T) -> Option> 272 | where 273 | T: Hash + Eq, 274 | { 275 | self.slots.get(key).and_then(|slot| { 276 | let mut buffer = vec![0; slot.len]; 277 | 278 | if let Err(err) = self.buffer.file.read_at(&mut buffer, slot.offset as u64) { 279 | error!("SlotedBuffer: Failed to read slot from buffer. The error: {err}"); 280 | return None; 281 | } 282 | 283 | Some(buffer) 284 | }) 285 | } 286 | } 287 | 288 | pub(super) struct Buffer { 289 | file: File, 290 | cursor: u64, 291 | size: usize, 292 | } 293 | 294 | impl Buffer { 295 | fn new() -> Self { 296 | debug!("Buffer: Trying to create"); 297 | Self { 298 | file: tempfile::tempfile().expect("The tempfile must be created"), 299 | cursor: 0, 300 | size: 0, 301 | } 302 | } 303 | 304 | fn reset(&mut self) { 305 | self.file 306 | .write_all_at(&vec![0; self.cursor as usize + 1], 0) 307 | .expect("Must be possibility to write into file"); 308 | self.cursor = 0; 309 | debug!("Buffer: Reset"); 310 | } 311 | 312 | fn push(&mut self, data: &[u8]) { 313 | self.file 314 | .write_all_at(data, self.cursor) 315 | .expect("Must be possibility to write into file"); 316 | self.cursor += data.len() as u64; 317 | 318 | self.size = std::cmp::max(self.size, self.cursor as usize); 319 | 320 | debug!("Buffer: Received a data to write") 321 | } 322 | 323 | fn filled_size(&self) -> usize { 324 | self.cursor as usize 325 | } 326 | 327 | pub(super) fn size(&self) -> usize { 328 | self.size 329 | } 330 | 331 | pub(super) fn as_fd(&self) -> BorrowedFd<'_> { 332 | self.file.as_fd() 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /crates/backend/src/managers/window_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use cache::CachedLayout; 3 | use config::Config; 4 | use dbus::{actions::Signal, notification::Notification}; 5 | use log::debug; 6 | use render::PangoContext; 7 | use shared::cached_data::CachedData; 8 | use std::{cell::RefCell, collections::VecDeque, path::PathBuf, rc::Rc}; 9 | use wayland_client::{ 10 | protocol::{wl_compositor::WlCompositor, wl_seat::WlSeat, wl_shm::WlShm}, 11 | Connection, 12 | }; 13 | use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1; 14 | use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1; 15 | use window::Window; 16 | 17 | mod banner_stack; 18 | mod buffer; 19 | mod cache; 20 | mod window; 21 | 22 | pub(crate) struct WindowManager { 23 | window: Option, 24 | 25 | pango_context: Option>>, 26 | cached_layouts: CachedData, 27 | 28 | signals: Vec, 29 | 30 | notification_queue: VecDeque, 31 | close_notifications: Vec, 32 | } 33 | 34 | impl WindowManager { 35 | pub(crate) fn init(config: &Config) -> anyhow::Result { 36 | let cached_layouts = config 37 | .displays() 38 | .filter_map(|display| match &display.layout { 39 | config::display::Layout::Default => None, 40 | config::display::Layout::FromPath { path_buf } => Some(path_buf), 41 | }) 42 | .collect(); 43 | 44 | let wm = Self { 45 | window: None, 46 | 47 | pango_context: None, 48 | cached_layouts, 49 | 50 | signals: vec![], 51 | notification_queue: VecDeque::new(), 52 | close_notifications: vec![], 53 | }; 54 | 55 | debug!("Window Manager: Created"); 56 | 57 | Ok(wm) 58 | } 59 | 60 | pub(crate) fn dispatch(&mut self) -> anyhow::Result { 61 | if let Some(window) = self.window.as_mut() { 62 | window.dispatch()?; 63 | } 64 | 65 | Ok(false) 66 | } 67 | 68 | pub(crate) fn update_cache(&mut self) -> bool { 69 | self.cached_layouts.update() 70 | } 71 | 72 | pub(crate) fn update_by_config(&mut self, config: &Config) -> Result<(), Error> { 73 | self.cached_layouts.extend_by_keys( 74 | config 75 | .displays() 76 | .filter_map(|display| match &display.layout { 77 | config::display::Layout::Default => None, 78 | config::display::Layout::FromPath { path_buf } => Some(path_buf.to_owned()), 79 | }) 80 | .collect(), 81 | ); 82 | 83 | if let Some(pango_context) = self.pango_context.as_ref() { 84 | pango_context 85 | .borrow_mut() 86 | .update_font_family(&config.general().font.name); 87 | } 88 | 89 | if let Some(window) = self.window.as_mut() { 90 | window.reconfigure(config); 91 | window.draw(config, &self.cached_layouts)?; 92 | window.frame(); 93 | window.commit(); 94 | } 95 | 96 | debug!("Window Manager: Updated the windows by updated config"); 97 | 98 | self.sync()?; 99 | Ok(()) 100 | } 101 | 102 | pub(crate) fn create_notification(&mut self, notification: Box) { 103 | self.notification_queue.push_back(*notification); 104 | } 105 | 106 | pub(crate) fn close_notification(&mut self, notification_id: u32) { 107 | self.close_notifications.push(notification_id); 108 | } 109 | 110 | pub(crate) fn show_window

( 111 | &mut self, 112 | wayland_connection: &Connection, 113 | protoctols: &P, 114 | config: &Config, 115 | ) -> Result<(), Error> 116 | where 117 | P: AsRef 118 | + AsRef 119 | + AsRef 120 | + AsRef 121 | + AsRef, 122 | { 123 | let mut notifications_limit = config.general().limit as usize; 124 | if notifications_limit == 0 { 125 | notifications_limit = usize::MAX; 126 | } 127 | 128 | if self 129 | .window 130 | .as_ref() 131 | .is_none_or(|window| window.total_banners() < notifications_limit) 132 | && !self.notification_queue.is_empty() 133 | { 134 | self.init_window(wayland_connection, protoctols, config)?; 135 | self.process_notification_queue(config)?; 136 | } 137 | 138 | Ok(()) 139 | } 140 | 141 | fn process_notification_queue(&mut self, config: &Config) -> Result<(), Error> { 142 | if let Some(window) = self.window.as_mut() { 143 | let mut notifications_limit = config.general().limit as usize; 144 | 145 | if notifications_limit == 0 { 146 | notifications_limit = usize::MAX 147 | } 148 | 149 | window.replace_by_indices(&mut self.notification_queue, config); 150 | 151 | let available_slots = notifications_limit.saturating_sub(window.total_banners()); 152 | let notifications_to_display: Vec<_> = self 153 | .notification_queue 154 | .drain(..available_slots.min(self.notification_queue.len())) 155 | .collect(); 156 | 157 | window.add_banners(notifications_to_display, config); 158 | 159 | self.update_window(config)?; 160 | self.sync()?; 161 | } 162 | 163 | Ok(()) 164 | } 165 | 166 | pub(crate) fn handle_close_notifications(&mut self, config: &Config) -> Result<(), Error> { 167 | if self.window.as_ref().is_some() && !self.close_notifications.is_empty() { 168 | let window = self.window.as_mut().unwrap(); 169 | 170 | let notifications = window.remove_banners_by_id(&self.close_notifications); 171 | self.close_notifications.clear(); 172 | 173 | if notifications.is_empty() { 174 | return Ok(()); 175 | } 176 | 177 | notifications.into_iter().for_each(|notification| { 178 | let notification_id = notification.id; 179 | self.signals.push(Signal::NotificationClosed { 180 | notification_id, 181 | reason: dbus::actions::ClosingReason::CallCloseNotification, 182 | }) 183 | }); 184 | 185 | self.process_notification_queue(config)?; 186 | } 187 | 188 | Ok(()) 189 | } 190 | 191 | pub(crate) fn remove_expired(&mut self, config: &Config) -> Result<(), Error> { 192 | if let Some(window) = self.window.as_mut() { 193 | let notifications = window.remove_expired_banners(config); 194 | 195 | if notifications.is_empty() { 196 | return Ok(()); 197 | } 198 | 199 | notifications.into_iter().for_each(|notification| { 200 | let notification_id = notification.id; 201 | self.signals.push(Signal::NotificationClosed { 202 | notification_id, 203 | reason: dbus::actions::ClosingReason::Expired, 204 | }) 205 | }); 206 | 207 | self.process_notification_queue(config)?; 208 | } 209 | 210 | Ok(()) 211 | } 212 | 213 | pub(crate) fn pop_signal(&mut self) -> Option { 214 | self.signals.pop() 215 | } 216 | 217 | pub(crate) fn handle_actions(&mut self, config: &Config) -> Result<(), Error> { 218 | //TODO: change it to actions which defines in config file 219 | 220 | if let Some(window) = self.window.as_mut() { 221 | window.handle_hover(config); 222 | 223 | let Some(signal) = window.handle_click(config) else { 224 | return Ok(()); 225 | }; 226 | 227 | self.signals.push(signal); 228 | self.process_notification_queue(config)?; 229 | } 230 | 231 | Ok(()) 232 | } 233 | 234 | pub(crate) fn reset_timeouts(&mut self) -> anyhow::Result<()> { 235 | if let Some(window) = self.window.as_mut() { 236 | window.reset_timeouts(); 237 | } 238 | 239 | Ok(()) 240 | } 241 | 242 | fn update_window(&mut self, config: &Config) -> Result<(), Error> { 243 | if let Some(window) = self.window.as_mut() { 244 | if window.is_empty() { 245 | return Ok(self.deinit_window()?); 246 | } 247 | 248 | window.draw(config, &self.cached_layouts)?; 249 | window.frame(); 250 | window.commit(); 251 | 252 | debug!("Window Manager: Updated the windows"); 253 | } 254 | 255 | Ok(()) 256 | } 257 | 258 | fn sync(&mut self) -> anyhow::Result<()> { 259 | if let Some(window) = self.window.as_mut() { 260 | window.sync()?; 261 | debug!("Window Manager: Roundtrip events for the windows"); 262 | } 263 | 264 | Ok(()) 265 | } 266 | 267 | fn init_window

( 268 | &mut self, 269 | wayland_connection: &Connection, 270 | protocols: &P, 271 | config: &Config, 272 | ) -> anyhow::Result 273 | where 274 | P: AsRef 275 | + AsRef 276 | + AsRef 277 | + AsRef 278 | + AsRef, 279 | { 280 | if self.window.is_none() { 281 | let pango_context = Rc::new(RefCell::new(PangoContext::from_font_family( 282 | &config.general().font.name, 283 | ))); 284 | self.pango_context = Some(pango_context.clone()); 285 | self.window = Some(Window::init( 286 | wayland_connection, 287 | protocols, 288 | pango_context, 289 | config, 290 | )?); 291 | 292 | debug!("Window Manager: Created a window"); 293 | 294 | Ok(true) 295 | } else { 296 | Ok(false) 297 | } 298 | } 299 | 300 | fn deinit_window(&mut self) -> anyhow::Result<()> { 301 | if self.window.as_mut().is_some() { 302 | self.pango_context = None; 303 | self.window = None; 304 | debug!("Window Manager: Closed window"); 305 | } 306 | 307 | Ok(()) 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /crates/dbus/src/notification.rs: -------------------------------------------------------------------------------- 1 | use super::{image::ImageData, text::Text}; 2 | use derive_more::Display; 3 | use std::{cmp::Ordering, collections::HashMap}; 4 | use zbus::zvariant::Value; 5 | 6 | #[derive(Debug)] 7 | pub struct Notification { 8 | pub id: u32, 9 | pub app_name: String, 10 | pub app_icon: String, 11 | pub summary: String, 12 | pub body: Text, 13 | pub expire_timeout: Timeout, 14 | pub hints: Hints, 15 | pub actions: Vec, 16 | pub is_read: bool, 17 | pub created_at: u64, 18 | } 19 | 20 | #[derive(Debug)] 21 | pub struct ScheduledNotification { 22 | pub id: u32, 23 | pub time: String, 24 | pub data: Box, 25 | } 26 | 27 | impl Ord for ScheduledNotification { 28 | fn cmp(&self, other: &Self) -> Ordering { 29 | self.time.cmp(&other.time) 30 | } 31 | } 32 | 33 | impl PartialOrd for ScheduledNotification { 34 | fn partial_cmp(&self, other: &Self) -> Option { 35 | Some(self.time.cmp(&other.time)) 36 | } 37 | } 38 | 39 | impl PartialEq for ScheduledNotification { 40 | fn eq(&self, other: &Self) -> bool { 41 | self.time == other.time 42 | } 43 | } 44 | 45 | impl Eq for ScheduledNotification {} 46 | 47 | #[derive(Debug, Clone)] 48 | pub struct Hints { 49 | /// The urgency level. 50 | pub urgency: Urgency, 51 | 52 | /// The type of notification this is. 53 | pub category: Category, 54 | 55 | /// This specifies the name of the desktop filename representing the calling program. 56 | /// This should be the same as the prefix used for the application's .desktop file. 57 | /// An example would be "rhythmbox" from "rhythmbox.desktop". 58 | /// This can be used by the daemon to retrieve the correct icon for the application, for logging purposes, etc. 59 | pub desktop_entry: Option, 60 | 61 | /// Raw data image format. 62 | pub image_data: Option, 63 | 64 | /// Alternative way to define the notification image 65 | pub image_path: Option, 66 | 67 | /// When set the server will not automatically remove the notification when an action has been invoked. 68 | /// The notification will remain resident in the server until it is explicitly removed by the user or by the sender. 69 | /// This hint is likely only useful when the server has the "persistence" capability. 70 | pub resident: Option, 71 | 72 | /// The path to a sound file to play when the notification pops up. 73 | pub sound_file: Option, 74 | 75 | /// A themeable named sound from the freedesktop.org sound naming specification to play when the notification pops up. 76 | /// Similar to icon-name, only for sounds. An example would be "message-new-instant". 77 | pub sound_name: Option, 78 | 79 | /// Causes the server to suppress playing any sounds, if it has that ability. 80 | /// This is usually set when the client itself is going to play its own sound. 81 | pub suppress_sound: Option, 82 | 83 | /// When set the server will treat the notification as transient and by-pass the server's persistence capability, if it should exist. 84 | pub transient: Option, 85 | 86 | /// Specifies the X and Y location on the screen that the notification should point to. 87 | pub coordinates: Option, 88 | 89 | /// When set, a server that has the "action-icons" capability will attempt to interpret any action identifier as a named icon. 90 | /// The localized display name will be used to annotate the icon for accessibility purposes. 91 | /// The icon name should be compliant with the Freedesktop.org Icon Naming Specification. 92 | pub action_icons: Option, 93 | 94 | /// Specifies the time to schedule the notification to be shown. 95 | pub schedule: Option, 96 | } 97 | 98 | impl Hints { 99 | fn get_hint_value<'a, T>(hints: &'a HashMap<&'a str, Value<'a>>, key: &str) -> Option 100 | where 101 | T: TryFrom<&'a Value<'a>>, 102 | { 103 | hints.get(key).and_then(|val| T::try_from(val).ok()) 104 | } 105 | } 106 | 107 | impl From>> for Hints { 108 | fn from(mut hints: HashMap<&str, Value>) -> Self { 109 | let urgency = hints 110 | .get("urgency") 111 | .and_then(Urgency::from_hint) 112 | .unwrap_or_default(); 113 | 114 | let category = hints 115 | .get("category") 116 | .and_then(Category::from_hint) 117 | .unwrap_or_default(); 118 | 119 | let image_data = ["image-data", "image_data", "icon-data", "icon_data"] 120 | .iter() 121 | .find_map(|&name| hints.remove(name)) 122 | .and_then(ImageData::from_hint); 123 | 124 | let image_path = Self::get_hint_value(&hints, "image-path"); 125 | let desktop_entry = Self::get_hint_value(&hints, "desktop-entry"); 126 | let sound_file = Self::get_hint_value(&hints, "sound-file"); 127 | let sound_name = Self::get_hint_value(&hints, "sound-name"); // NOTE: http://0pointer.de/public/sound-naming-spec.html 128 | let resident = Self::get_hint_value(&hints, "resident"); 129 | let suppress_sound = Self::get_hint_value(&hints, "suppress-sound"); 130 | let transient = Self::get_hint_value(&hints, "transient"); 131 | let action_icons = Self::get_hint_value(&hints, "action_icons"); 132 | let schedule = Self::get_hint_value(&hints, "schedule"); 133 | let coordinates = Coordinates::from_hints(&hints); 134 | 135 | Hints { 136 | urgency, 137 | category, 138 | image_data, 139 | image_path, 140 | desktop_entry, 141 | resident, 142 | sound_file, 143 | sound_name, 144 | suppress_sound, 145 | transient, 146 | coordinates, 147 | action_icons, 148 | schedule, 149 | } 150 | } 151 | } 152 | 153 | #[derive(Debug)] 154 | pub struct NotificationAction { 155 | #[allow(unused)] 156 | action_key: String, 157 | #[allow(unused)] 158 | localized_string: String, 159 | } 160 | 161 | impl NotificationAction { 162 | pub fn from_vec(vec: &[&str]) -> Vec { 163 | let mut actions: Vec = Vec::new(); 164 | 165 | if vec.len() >= 2 { 166 | for chunk in vec.chunks(2) { 167 | actions.push(Self { 168 | action_key: chunk[0].into(), 169 | localized_string: chunk[1].into(), 170 | }); 171 | } 172 | } 173 | 174 | actions 175 | } 176 | } 177 | 178 | #[derive(Debug, Clone)] 179 | pub struct Coordinates { 180 | pub x: i32, 181 | pub y: i32, 182 | } 183 | 184 | impl Coordinates { 185 | fn from_hints(hints: &HashMap<&str, Value>) -> Option { 186 | let x = hints.get("x").and_then(|val| i32::try_from(val).ok()); 187 | let y = hints.get("y").and_then(|val| i32::try_from(val).ok()); 188 | 189 | match (x, y) { 190 | (Some(x), Some(y)) => Some(Self { x, y }), 191 | _ => None, 192 | } 193 | } 194 | } 195 | 196 | #[derive(Debug, Clone, Default)] 197 | pub enum Category { 198 | Device(CategoryEvent), 199 | Email(CategoryEvent), 200 | InstantMessage(CategoryEvent), 201 | Network(CategoryEvent), 202 | Presence(CategoryEvent), 203 | Transfer(CategoryEvent), 204 | #[default] 205 | Unknown, 206 | } 207 | 208 | impl Category { 209 | pub fn from_hint(hint: &Value<'_>) -> Option { 210 | String::try_from(hint).ok().map(|s| Self::from(s.as_str())) 211 | } 212 | } 213 | 214 | impl From<&str> for Category { 215 | fn from(value: &str) -> Self { 216 | match value { 217 | "device" => Self::Device(CategoryEvent::Generic), 218 | "device.added" => Self::Device(CategoryEvent::Added), 219 | "device.removed" => Self::Device(CategoryEvent::Removed), 220 | "device.error" => Self::Device(CategoryEvent::Error), 221 | "email" => Self::Email(CategoryEvent::Generic), 222 | "email.arrived" => Self::Email(CategoryEvent::Arrived), 223 | "email.bounced" => Self::Email(CategoryEvent::Bounced), 224 | "im" => Self::InstantMessage(CategoryEvent::Generic), 225 | "im.received" => Self::InstantMessage(CategoryEvent::Received), 226 | "im.error" => Self::InstantMessage(CategoryEvent::Error), 227 | "network" => Self::Network(CategoryEvent::Generic), 228 | "network.connected" => Self::Network(CategoryEvent::Connected), 229 | "network.disconnected" => Self::Network(CategoryEvent::Disconnected), 230 | "network.error" => Self::Network(CategoryEvent::Error), 231 | "presence" => Self::Presence(CategoryEvent::Generic), 232 | "presence.online" => Self::Presence(CategoryEvent::Online), 233 | "presence.offline" => Self::Presence(CategoryEvent::Offline), 234 | "transfer" => Self::Transfer(CategoryEvent::Generic), 235 | "transfer.complete" => Self::Transfer(CategoryEvent::Complete), 236 | "transfer.error" => Self::Transfer(CategoryEvent::Error), 237 | _ => Self::Unknown, 238 | } 239 | } 240 | } 241 | 242 | #[derive(Debug, Clone)] 243 | pub enum CategoryEvent { 244 | Generic, 245 | Added, 246 | Removed, 247 | Arrived, 248 | Bounced, 249 | Received, 250 | Error, 251 | Connected, 252 | Disconnected, 253 | Offline, 254 | Online, 255 | Complete, 256 | } 257 | 258 | #[derive(Default, Debug, Clone, Display)] 259 | pub enum Timeout { 260 | Millis(u32), 261 | Never, 262 | #[default] 263 | Configurable, 264 | } 265 | 266 | impl From for Timeout { 267 | fn from(value: i32) -> Self { 268 | match value { 269 | t if t < -1 => todo!(), 270 | 0 => Self::Never, 271 | -1 => Self::Configurable, 272 | t => Self::Millis(t as u32), 273 | } 274 | } 275 | } 276 | 277 | #[derive(Debug, Clone, Copy, Default, Display, PartialEq, Eq)] 278 | pub enum Urgency { 279 | Low, 280 | #[default] 281 | Normal, 282 | Critical, 283 | } 284 | 285 | impl Urgency { 286 | pub fn from_hint(hint: &Value<'_>) -> Option { 287 | fn to_urgency>(val: T) -> Urgency { 288 | val.into() 289 | } 290 | 291 | u8::try_from(hint) 292 | .map(to_urgency) 293 | .ok() 294 | .or_else(|| String::try_from(hint).map(to_urgency).ok()) 295 | } 296 | } 297 | 298 | impl From for Urgency { 299 | fn from(value: u8) -> Self { 300 | match value { 301 | 0 => Self::Low, 302 | 1 => Self::Normal, 303 | 2 => Self::Critical, 304 | _ => Default::default(), 305 | } 306 | } 307 | } 308 | 309 | impl From<&Urgency> for u8 { 310 | fn from(value: &Urgency) -> Self { 311 | match value { 312 | Urgency::Low => 0, 313 | Urgency::Normal => 1, 314 | Urgency::Critical => 2, 315 | } 316 | } 317 | } 318 | 319 | impl From<&str> for Urgency { 320 | fn from(value: &str) -> Self { 321 | match value.to_lowercase().as_str() { 322 | "low" => Self::Low, 323 | "normal" => Self::Normal, 324 | "critical" => Self::Critical, 325 | _ => Default::default(), 326 | } 327 | } 328 | } 329 | 330 | impl From for Urgency { 331 | fn from(value: String) -> Self { 332 | >::from(&value) 333 | } 334 | } 335 | 336 | impl Ord for Urgency { 337 | fn cmp(&self, other: &Self) -> Ordering { 338 | Into::::into(self).cmp(&other.into()) 339 | } 340 | } 341 | 342 | impl PartialOrd for Urgency { 343 | fn partial_cmp(&self, other: &Self) -> Option { 344 | Some(self.cmp(other)) 345 | } 346 | } 347 | --------------------------------------------------------------------------------