diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..e6ca28bc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +- [ ] I have disclosed use of any AI generated code in my commit messages. + - If you are using an LLM, and do not fully understand the changes it is making to the code base, do not create a PR. + - In our experience, AI generated code often results in overly complex code that lacks enough context for a proper fix or feature inclusion. This results in considerably longer code reviews. Due to this, AI authored or partially authored PRs may be closed without comment. +- [ ] I understand these changes in full and will be able to respond to review comments. +- [ ] My change is accurately described in the commit message. +- [ ] My contribution is tested and working as described. +- [ ] I have read the [Developer Certificate of Origin](https://developercertificate.org/) and certify my contribution under its conditions. + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bd7bf50..7897eb01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,9 @@ jobs: - name: Checkout sources uses: actions/checkout@v3 - name: Rust toolchain - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@stable with: - toolchain: stable - override: true - profile: minimal components: rustfmt - default: true - name: Cargo cache uses: actions/cache@v3 with: @@ -29,10 +25,7 @@ jobs: ~/.cargo/git key: ${{ runner.os }}-cargo-rust_stable-${{ hashFiles('**/Cargo.toml') }} - name: Format - uses: actions-rs/cargo@v1 - with: - command: fmt - args: -- --check + run: cargo fmt -- --check tests: needs: @@ -40,13 +33,17 @@ jobs: strategy: fail-fast: false matrix: - features: - - "" # for cosmic-comp, don't remove! - - 'winit_debug' - - 'winit_tokio' - - winit - - winit_wgpu - - wayland + test_args: + - --no-default-features --features "" # for cosmic-comp, don't remove! + - --no-default-features --features "winit_debug" + - --no-default-features --features "winit_tokio" + - --no-default-features --features "winit" + - --no-default-features --features "winit_wgpu" + - --no-default-features --features "wayland" + - --no-default-features --features "applet" + - --no-default-features --features "desktop,smol" + - --no-default-features --features "desktop,tokio" + - -p cosmic-theme runs-on: ubuntu-22.04 steps: - name: Checkout sources @@ -68,18 +65,11 @@ jobs: - name: System dependencies run: sudo apt-get update; sudo apt-get install -y libxkbcommon-dev libwayland-dev - name: Rust toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - default: true + uses: dtolnay/rust-toolchain@stable - name: Test features - uses: actions-rs/cargo@v1 + run: cargo test ${{ matrix.test_args }} -- --test-threads=1 env: RUST_BACKTRACE: full - with: - command: test - args: --no-default-features --features "${{ matrix.features }}" examples: needs: @@ -90,6 +80,8 @@ jobs: examples: - "application" - "open-dialog" + - "context-menu" + - "nav-context" runs-on: ubuntu-22.04 steps: - name: Checkout sources @@ -111,16 +103,8 @@ jobs: - name: System dependencies run: sudo apt-get update; sudo apt-get install -y libxkbcommon-dev libwayland-dev - name: Rust toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - default: true - - name: Test example - uses: actions-rs/cargo@v1 + uses: dtolnay/rust-toolchain@stable + - name: Check example + run: cargo check -p "${{ matrix.examples }}" env: RUST_BACKTRACE: full - with: - command: check - args: -p "${{ matrix.examples }}" - diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..3e3a042e --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,36 @@ +name: Pages + +on: + push: + branches: + - master + +jobs: + pages: + runs-on: ubuntu-latest + + steps: + - name: Checkout sources + uses: actions/checkout@v3 + with: + submodules: recursive + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2025-07-31 + - name: System dependencies + run: sudo apt-get update; sudo apt-get install -y libxkbcommon-dev libwayland-dev + - name: Build documentation + run: | + RUSTDOCFLAGS="--cfg docsrs" \ + cargo +nightly-2025-07-31 doc --no-deps \ + -p cosmic-client-toolkit \ + -p cosmic-protocols \ + -p libcosmic \ + --verbose --features tokio,winit,wayland,desktop,single-instance,applet,xdg-portal,multi-window + - name: Deploy documentation + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./target/doc + force_orphan: true diff --git a/.gitmodules b/.gitmodules index 2338d056..fdaf8abe 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,6 @@ path = iced url = https://github.com/pop-os/iced.git branch = master -[submodule "examples/design-demo"] - path = examples/design-demo - url = https://github.com/pop-os/cosmic-design-demo +[submodule "icon-theme"] + path = cosmic-icons + url = https://github.com/pop-os/cosmic-icons diff --git a/.vscode/settings.json b/.vscode/settings.json index ee258746..a06e580a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { "rust-analyzer.check.overrideCommand": ["just", "check-json"], - "git-blame.gitWebUrl": "" + "git-blame.gitWebUrl": "", } diff --git a/Cargo.toml b/Cargo.toml index 962c14f1..d73da2dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,36 +1,105 @@ [package] name = "libcosmic" -version = "0.1.0" -edition = "2021" +version = "1.0.0" +edition = "2024" +rust-version = "1.90" [lib] name = "cosmic" [features] +default = [ + "winit", + "tokio", + "a11y", + "dbus-config", + "x11", + "iced-wayland", + "multi-window", +] +advanced-shaping = ["iced/advanced-shaping"] # Accessibility support a11y = ["iced/a11y", "iced_accessibility"] +# Enable about widget +about = [] # Builds support for animated images -animated-image = ["image", "dep:async-fs", "tokio?/io-util", "tokio?/fs"] +animated-image = [ + "dep:async-fs", + "image/gif", + "image/webp", + "image/png", + "tokio?/io-util", + "tokio?/fs", +] +# XXX autosize should not be used on winit windows unless dialogs +autosize = [] +applet = [ + "autosize", + "winit", + "wayland", + "tokio", + "cosmic-panel-config", + "ron", + "multi-window", +] +applet-token = ["applet"] +# Use the cosmic-settings-daemon for config handling on Linux targets +dbus-config = [] # Debug features debug = ["iced/debug"] # Enables pipewire support in ashpd, if ashpd is enabled pipewire = ["ashpd?/pipewire"] # Enables process spawning helper -process = ["nix"] +process = ["dep:libc", "dep:rustix"] +# Use rfd for file dialogs +rfd = ["dep:rfd"] +# Enables desktop files helpers +desktop = [ + "process", + "dep:cosmic-settings-config", + "dep:freedesktop-desktop-entry", + "dep:image-extras", + "dep:mime", + "dep:shlex", + "tokio?/io-util", + "tokio?/net", +] +# Enables launching desktop files inside systemd scopes +desktop-systemd-scope = ["desktop", "dep:zbus"] # Enables keycode serialization serde-keycode = ["iced_core/serde"] +# Prevents multiple separate process instances. +single-instance = ["zbus/blocking-api", "ron"] # smol async runtime -smol = ["iced/smol", "zbus?/async-io"] -# Tokio async runtime -tokio = ["dep:tokio", "ashpd?/tokio", "iced/tokio", "zbus?/tokio"] -# Wayland window support -wayland = [ - "ashpd?/wayland", - "iced_runtime/wayland", - "iced/wayland", - "iced_sctk", - "cctk", +smol = ["dep:smol", "iced/smol", "zbus?/async-io", "rfd?/async-std"] +tokio = [ + "dep:tokio", + "ashpd?/tokio", + "iced/tokio", + "rfd?/tokio", + "zbus?/tokio", + "cosmic-config/tokio", ] +# Tokio async runtime +# Wayland window support +iced-wayland = [ + "ashpd?/wayland", + "autosize", + "iced/wayland", + "iced_winit/wayland", + "surface-message", +] +wayland = [ + "iced-wayland", + "iced_runtime/cctk", + "iced_winit/cctk", + "iced_wgpu/cctk", + "iced/cctk", + "dep:cctk", +] +surface-message = [] +# multi-window support +multi-window = [] # Render with wgpu wgpu = ["iced/wgpu", "iced_wgpu"] # X11 window support via winit @@ -40,35 +109,81 @@ winit_tokio = ["winit", "tokio"] winit_wgpu = ["winit", "wgpu"] # Enables XDG portal integrations xdg-portal = ["ashpd"] -# XXX Use "a11y"; which is causing a panic currently -applet = ["wayland", "tokio", "cosmic-panel-config", "ron"] -applet-token = [] -single-instance = ["dep:zbus", "serde", "ron"] +qr_code = ["iced/qr_code"] +markdown = ["iced/markdown"] +highlighter = ["iced/highlighter"] +async-std = [ + "dep:async-std", + "ashpd?/async-std", + "rfd?/async-std", + "zbus?/async-io", + "iced/async-std", +] +x11 = ["iced/x11", "iced_winit/x11"] [dependencies] apply = "0.3.0" -derive_setters = "0.1.5" -lazy_static = "1.4.0" -palette = "0.7.3" -tokio = { version = "1.24.2", optional = true } -cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "5faec87", optional = true } -slotmap = "1.0.6" -fraction = "0.13.0" +ashpd = { version = "0.12.3", default-features = false, optional = true } +async-fs = { version = "2.2", optional = true } +async-std = { version = "1.13", optional = true } +auto_enums = "0.8.8" +cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "160b086", optional = true } +jiff = "0.2" cosmic-config = { path = "cosmic-config" } -tracing = "0.1" -image = { version = "0.24.6", optional = true } -thiserror = "1.0.44" -async-fs = { version = "1.6", optional = true } -ashpd = { version = "0.5.0", default-features = false, optional = true } -url = "2.4.0" -unicode-segmentation = "1.6" -css-color = "0.2.5" -nix = { version = "0.26", optional = true } -zbus = {version = "3.14.1", default-features = false, optional = true} -serde = { version = "1.0.180", optional = true } +cosmic-settings-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true } +# Internationalization +i18n-embed = { version = "0.16.0", features = [ + "fluent-system", + "desktop-requester", +] } +i18n-embed-fl = "0.10" +rust-embed = "8.11.0" +css-color = "0.2.8" +derive_setters = "0.1.9" +futures = "0.3" +image = { version = "0.25.10", default-features = false, features = [ + "ico", + "jpeg", + "png", +] } +image-extras = { version = "0.1.0", default-features = false, features = [ + "xpm", + "xbm", +], optional = true } +libc = { version = "0.2.183", optional = true } +log = "0.4" +mime = { version = "0.3.17", optional = true } +palette = "0.7.6" +rfd = { version = "0.16.0", default-features = false, features = [ + "xdg-portal", +], optional = true } +rustix = { version = "1.1", features = ["pipe", "process"], optional = true } +serde = { version = "1.0.228", features = ["derive"] } +slotmap = "1.1.1" +smol = { version = "2.0.2", optional = true } +thiserror = "2.0.18" +taffy = { version = "0.9.2", features = ["grid"] } +tokio = { version = "1.50.0", optional = true } +tracing = "0.1.44" +unicode-segmentation = "1.12" +url = "2.5.8" +zbus = { version = "5.14.0", default-features = false, optional = true } +float-cmp = "0.10.0" -[target.'cfg(unix)'.dependencies] -freedesktop-icons = "0.2.4" +# Enable DBus feature on Linux targets +[target.'cfg(target_os = "linux")'.dependencies] +cosmic-config = { path = "cosmic-config", features = ["dbus"] } +cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings" } +zbus = { version = "5.14.0", default-features = false } + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +freedesktop-icons = { package = "cosmic-freedesktop-icons", git = "https://github.com/pop-os/freedesktop-icons" } +freedesktop-desktop-entry = { version = "0.8.1", optional = true } +shlex = { version = "1.3.0", optional = true } + +[target.'cfg(any(not(unix), target_os = "macos"))'.dependencies] +# Used to embed bundled icons for non-unix platforms. +phf = { version = "0.13.1", features = ["macros"] } [dependencies.cosmic-theme] path = "cosmic-theme" @@ -76,7 +191,14 @@ path = "cosmic-theme" [dependencies.iced] path = "./iced" default-features = false -features = ["advanced", "image", "svg", "lazy"] +features = [ + "advanced", + "image-without-codecs", + "lazy", + "svg", + "web-colors", + "tiny-skia", +] [dependencies.iced_runtime] path = "./iced/runtime" @@ -97,19 +219,11 @@ path = "./iced/futures" [dependencies.iced_accessibility] path = "./iced/accessibility" - optional = true [dependencies.iced_tiny_skia] path = "./iced/tiny_skia" -[dependencies.iced_style] -path = "./iced/style" - -[dependencies.iced_sctk] -path = "./iced/sctk" -optional = true - [dependencies.iced_winit] path = "./iced/winit" optional = true @@ -120,28 +234,24 @@ optional = true [dependencies.cosmic-panel-config] git = "https://github.com/pop-os/cosmic-panel" +# path = "../cosmic-panel/cosmic-panel-config" optional = true [dependencies.ron] -version = "0.8" +version = "0.12" optional = true -[dependencies.taffy] -git = "https://github.com/DioxusLabs/taffy" -rev = "7781c70" -features = ["grid"] - [workspace] members = [ - "cosmic-config", - "cosmic-config-derive", - "cosmic-theme", - "examples/*", + "cosmic-config", + "cosmic-config-derive", + "cosmic-theme", + "examples/*", ] -exclude = ["examples/design-demo", "iced"] +exclude = ["iced"] -[patch."https://github.com/pop-os/libcosmic"] -libcosmic = { path = "./" } +[workspace.dependencies] +dirs = "6.0.0" -# [patch."https://github.com/pop-os/cosmic-time"] -# cosmic-time = { path = "../cosmic-time" } +[dev-dependencies] +tempfile = "3.27.0" diff --git a/README.md b/README.md index efd09412..23da97bc 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,33 @@ # LIBCOSMIC -A platform toolkit based on iced which provides the building blocks for developing the -future COSMIC desktop environment. Applications and applets alike are equally supported -targets of Libcosmic. Applets integrate directly with COSMIC's interface as shell -components, which was made possible by the Layer Shell protocol of Wayland. +A platform toolkit based on iced for creating applets and applications for the COSMIC™ desktop. -## Building +## Documentation -Libcosmic is written entirely in Rust, with minimal dependence on system libraries. On -Pop!_OS, the following dependencies are all that's necessary compile the cosmic library: +- [API Documentation](https://pop-os.github.io/libcosmic/cosmic/): Automatically generated from this repository via `cargo doc` +- [libcosmic Book](https://pop-os.github.io/libcosmic-book/): A reference for learning libcosmic + +## Templates + +- https://github.com/pop-os/cosmic-app-template: Application project template +- https://github.com/pop-os/cosmic-applet-template: Panel applet project template + +## Dependencies + +While libcosmic is written entirely in Rust, some of its dependencies may require shared system library headers to be installed. On Pop!_OS, the following dependencies are all that's necessary to compile a typical COSMIC project: ```sh -sudo apt install cargo cmake just libexpat1-dev libfontconfig-dev libfreetype-dev pkg-config +sudo apt install cargo cmake just libexpat1-dev libfontconfig-dev libfreetype-dev libxkbcommon-dev pkgconf ``` +## Examples + Some examples are included in the [examples](./examples) directory to to kickstart your COSMIC adventure. To run them, you need to clone the repository with the following commands: ```sh -git clone https://github.com/pop-os/libcosmic +git clone --recurse-submodules https://github.com/pop-os/libcosmic cd libcosmic -git submodule update --init --recursive ``` If you have already cloned the repository, run these to sync with the latest updates: @@ -29,22 +36,11 @@ If you have already cloned the repository, run these to sync with the latest upd git fetch origin git checkout master git reset --hard origin/master -git submodule update --init --recursive ``` -The examples may then be run by their cargo project names, such as `just run cosmic-design-demo`. +The examples may then be run by their cargo project names, such as `just run application`. -To create a new COSMIC project, use `cargo new {{name_of_project}}` to create a new -project workspace, edit the `Cargo.toml` contained within, and add this to begin. - -```toml -[workspace.dependencies.libcosmic] -git = "https://github.com/pop-os/libcosmic" -default-features = false -features = ["wayland", "tokio"] -``` - -### Cargo Features +## Cargo Features Available cargo features to choose from: @@ -74,10 +70,6 @@ Available cargo features to choose from: - [COSMIC Text Editor](https://github.com/pop-os/cosmic-text-editor) - [COSMIC Settings](https://github.com/pop-os/cosmic-settings) -## Documentation - -Documentation can be found [here](https://pop-os.github.io/docs/). - ## Licence Licensed under the [Mozilla Public License 2.0](https://choosealicense.com/licenses/mpl-2.0). diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..4ce0aa9e --- /dev/null +++ b/build.rs @@ -0,0 +1,63 @@ +use std::env; + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + + if env::var_os("CARGO_CFG_UNIX").is_none() + || env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") + { + generate_bundled_icons(); + } +} + +fn generate_bundled_icons() { + println!("cargo::rerun-if-changed=cosmic-icons"); + + let manifest_dir = std::path::Path::new(std::env!("CARGO_MANIFEST_DIR")); + let icon_paths = [ + "cosmic-icons/freedesktop/scalable", + "cosmic-icons/extra/scalable", + ]; + + let key_value_assignments = icon_paths + .into_iter() + .map(|path| manifest_dir.join(path)) + .inspect(|icon_path| assert!(icon_path.exists(), "path = {icon_path:?}")) + .map(|icon_path| std::fs::read_dir(icon_path).unwrap()) + .flat_map(|dir| { + dir.flat_map(|entry| entry.unwrap().path().read_dir().unwrap()) + .map(|entry| { + let entry = entry.unwrap(); + let path = entry.path().canonicalize().unwrap(); + let file_name = path.file_stem().unwrap().to_str().unwrap().to_owned(); + let path = path.into_os_string().into_string().unwrap(); + (file_name, path) + }) + }) + .fold( + std::collections::BTreeMap::new(), + |mut set, (name, path)| { + set.insert(name, path); + set + }, + ) + .into_iter() + .fold(String::new(), |mut output, (name, path)| { + // This changes the escape character to the one used by Windows. + #[cfg(windows)] + let path = path.replace("\\", "/"); + output.push_str(&format!(" \"{name}\" => include_bytes!(\"{path}\"),\n")); + output + }); + + let code = [ + "static ICONS: phf::Map<&'static str, &'static [u8]> = phf::phf_map!(\n", + &key_value_assignments, + ");", + ] + .concat(); + + let out_dir = std::env::var_os("OUT_DIR").unwrap(); + let out_file = std::path::Path::new(&out_dir).join("bundled_icons.rs"); + std::fs::write(&out_file, &code).unwrap(); +} diff --git a/cosmic-config-derive/Cargo.toml b/cosmic-config-derive/Cargo.toml index 44f960ec..9d5f4b88 100644 --- a/cosmic-config-derive/Cargo.toml +++ b/cosmic-config-derive/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "cosmic-config-derive" -version = "0.1.0" -edition = "2021" +version = "1.0.0" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] proc-macro = true [dependencies] -syn = "1.0" +syn = "2.0" quote = "1.0" diff --git a/cosmic-config-derive/src/lib.rs b/cosmic-config-derive/src/lib.rs index 0e27afbb..cc19a91e 100644 --- a/cosmic-config-derive/src/lib.rs +++ b/cosmic-config-derive/src/lib.rs @@ -2,7 +2,7 @@ use proc_macro::TokenStream; use quote::quote; use syn::{self}; -#[proc_macro_derive(CosmicConfigEntry)] +#[proc_macro_derive(CosmicConfigEntry, attributes(version, id))] pub fn cosmic_config_entry_derive(input: TokenStream) -> TokenStream { // Construct a representation of Rust code as a syntax tree // that we can manipulate @@ -13,8 +13,29 @@ pub fn cosmic_config_entry_derive(input: TokenStream) -> TokenStream { } fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { + let attributes = &ast.attrs; + let version = attributes + .iter() + .find_map(|attr| { + if attr.path().is_ident("version") { + match attr.meta { + syn::Meta::NameValue(syn::MetaNameValue { + value: + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(ref lit_int), + .. + }), + .. + }) => Some(lit_int.base10_parse::().unwrap()), + _ => None, + } + } else { + None + } + }) + .unwrap_or(0); + let name = &ast.ident; - // let generics = &ast.generics; // Get the fields of the struct let fields = match ast.data { @@ -28,7 +49,7 @@ fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { let write_each_config_field = fields.iter().map(|field| { let field_name = &field.ident; quote! { - cosmic_config::ConfigSet::set(config, stringify!(#field_name), &self.#field_name)?; + cosmic_config::ConfigSet::set(&tx, stringify!(#field_name), &self.#field_name)?; } }); @@ -38,28 +59,57 @@ fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { quote! { match cosmic_config::ConfigGet::get::<#field_type>(config, stringify!(#field_name)) { Ok(#field_name) => default.#field_name = #field_name, + Err(why) if matches!(why, cosmic_config::Error::NoConfigDirectory) => (), Err(e) => errors.push(e), } } }); - // // Get the existing where clause or create a new one if it doesn't exist - // let mut where_clause = ast - // .generics - // .where_clause - // .clone() - // .unwrap_or_else(|| parse_quote!(where)); + let update_each_config_field = fields.iter().map(|field| { + let field_name = &field.ident; + let field_type = &field.ty; + quote! { + stringify!(#field_name) => { + match cosmic_config::ConfigGet::get::<#field_type>(config, stringify!(#field_name)) { + Ok(value) => { + if self.#field_name != value { + keys.push(stringify!(#field_name)); + } + self.#field_name = value; + }, + Err(e) => { + errors.push(e); + } + } + } + } + }); - // // Add your additional constraints to the where clause - // // Here, we add the constraint 'T: Debug' to all generic parameters - // for param in ast.generics.params.iter() { - // where_clause - // .predicates - // .push(parse_quote!(#param: ::std::default::Default + ::serde::Serialize + ::serde::de::DeserializeOwned)); - // } + let setters = fields.iter().filter_map(|field| { + let field_name = &field.ident.as_ref()?; + let field_type = &field.ty; + let setter_name = quote::format_ident!("set_{}", field_name); + let doc = format!("Sets [`{name}::{field_name}`] and writes to [`cosmic_config::Config`] if changed"); + Some(quote! { + #[doc = #doc] + /// + /// Returns `Ok(true)` when the field's value has changed and was written to disk + pub fn #setter_name(&mut self, config: &cosmic_config::Config, value: #field_type) -> Result { + if self.#field_name != value { + self.#field_name = value; + cosmic_config::ConfigSet::set(config, stringify!(#field_name), &self.#field_name)?; + Ok(true) + } else { + Ok(false) + } + } + }) + }); - let gen = quote! { + let generate = quote! { impl CosmicConfigEntry for #name { + const VERSION: u64 = #version; + fn write_entry(&self, config: &cosmic_config::Config) -> Result<(), cosmic_config::Error> { let tx = config.transaction(); #(#write_each_config_field)* @@ -78,8 +128,24 @@ fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { Err((errors, default)) } } + + fn update_keys>(&mut self, config: &cosmic_config::Config, changed_keys: &[T]) -> (Vec, Vec<&'static str>){ + let mut keys = Vec::with_capacity(changed_keys.len()); + let mut errors = Vec::new(); + for key in changed_keys.iter() { + match key.as_ref() { + #(#update_each_config_field)* + _ => (), + } + } + (errors, keys) + } + } + + impl #name { + #(#setters)* } }; - gen.into() + generate.into() } diff --git a/cosmic-config/Cargo.toml b/cosmic-config/Cargo.toml index 3593ae38..0a7653e0 100644 --- a/cosmic-config/Cargo.toml +++ b/cosmic-config/Cargo.toml @@ -1,22 +1,33 @@ [package] name = "cosmic-config" -version = "0.1.0" -edition = "2021" +version = "1.0.0" +edition = "2024" [features] default = ["macro", "subscription"] +dbus = ["dep:zbus", "cosmic-settings-daemon", "futures-util", "subscription"] macro = ["cosmic-config-derive"] subscription = ["iced_futures"] [dependencies] -# For redox support +cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } +zbus = { version = "5.14.0", default-features = false, optional = true } atomicwrites = { git = "https://github.com/jackpot51/rust-atomicwrites" } -calloop = { version = "0.12.2", optional = true } -dirs = "5.0.1" -notify = "6.0.0" -ron = "0.8.0" -serde = "1.0.152" +calloop = { version = "0.14.4", optional = true } +notify = "8.2.0" +ron = "0.12.0" +serde = "1.0.228" cosmic-config-derive = { path = "../cosmic-config-derive/", optional = true } -iced = { path = "../iced/", default-features = false, optional = true } +iced = { path = "../iced/", default-features = false, optional = true } iced_futures = { path = "../iced/futures/", default-features = false, optional = true } +futures-util = { version = "0.3", optional = true } +dirs.workspace = true +tokio = { version = "1.50", optional = true, features = ["time"] } +async-std = { version = "1.13", optional = true } +tracing = "0.1" +[target.'cfg(unix)'.dependencies] +xdg = "3.0" + +[target.'cfg(windows)'.dependencies] +known-folders = "1.4.2" diff --git a/cosmic-config/src/dbus.rs b/cosmic-config/src/dbus.rs new file mode 100644 index 00000000..da7bcb68 --- /dev/null +++ b/cosmic-config/src/dbus.rs @@ -0,0 +1,262 @@ +use std::{any::TypeId, ops::Deref}; + +use crate::{CosmicConfigEntry, Update}; +use cosmic_settings_daemon::{Changed, ConfigProxy, CosmicSettingsDaemonProxy}; +use futures_util::SinkExt; +use iced_futures::{ + Subscription, + futures::{self, StreamExt, future::pending}, + stream, +}; + +pub async fn settings_daemon_proxy() -> zbus::Result> { + let conn = zbus::Connection::session().await?; + CosmicSettingsDaemonProxy::new(&conn).await +} + +#[derive(Debug)] +pub struct Watcher { + proxy: ConfigProxy<'static>, +} + +impl Deref for Watcher { + type Target = ConfigProxy<'static>; + #[inline] + fn deref(&self) -> &Self::Target { + &self.proxy + } +} + +impl Watcher { + pub async fn new_config( + settings_daemon_proxy: &CosmicSettingsDaemonProxy<'static>, + id: &str, + version: u64, + ) -> zbus::Result { + let (path, name) = settings_daemon_proxy.watch_config(id, version).await?; + ConfigProxy::builder(settings_daemon_proxy.inner().connection()) + .path(path)? + .destination(name)? + .build() + .await + .map(|proxy| Self { proxy }) + } + + pub async fn new_state( + settings_daemon_proxy: &CosmicSettingsDaemonProxy<'static>, + id: &str, + version: u64, + ) -> zbus::Result { + let (path, name) = settings_daemon_proxy.watch_state(id, version).await?; + ConfigProxy::builder(settings_daemon_proxy.inner().connection()) + .path(path)? + .destination(name)? + .build() + .await + .map(|proxy| Self { proxy }) + } +} + +#[derive(Clone)] +struct Wrapper( + TypeId, + CosmicSettingsDaemonProxy<'static>, + &'static str, + bool, +); + +impl std::hash::Hash for Wrapper { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +#[allow(clippy::too_many_lines)] +pub fn watcher_subscription( + settings_daemon: CosmicSettingsDaemonProxy<'static>, + config_id: &'static str, + is_state: bool, +) -> iced_futures::Subscription> { + let id = std::any::TypeId::of::(); + Subscription::run_with( + Wrapper(id, settings_daemon, config_id, is_state), + |&Wrapper(_, ref settings_daemon, ref config_id, ref is_state)| { + let is_state = *is_state; + let config_id = *config_id; + let settings_daemon = settings_daemon.clone(); + enum Change { + Changes(Changed), + OwnerChanged(bool), + } + stream::channel( + 5, + move |mut tx: futures::channel::mpsc::Sender>| async move { + let version = T::VERSION; + + let Ok(cosmic_config) = (if is_state { + crate::Config::new_state(config_id, version) + } else { + crate::Config::new(config_id, version) + }) else { + pending::<()>().await; + unreachable!(); + }; + + let mut attempts = 0; + + loop { + let watcher = if is_state { + Watcher::new_state(&settings_daemon, config_id, version).await + } else { + Watcher::new_config(&settings_daemon, config_id, version).await + }; + let Ok(watcher) = watcher else { + tracing::error!("Failed to create watcher for {config_id}"); + + #[cfg(feature = "tokio")] + ::tokio::time::sleep(::tokio::time::Duration::from_secs( + 2_u64.pow(attempts), + )) + .await; + #[cfg(feature = "async-std")] + async_std::task::sleep(std::time::Duration::from_secs( + 2_u64.pow(attempts), + )) + .await; + #[cfg(not(any(feature = "tokio", feature = "async-std")))] + { + pending::<()>().await; + unreachable!(); + } + attempts += 1; + // The settings daemon has exited + continue; + }; + let Ok(changes) = watcher.receive_changed().await else { + tracing::error!("Failed to listen for changes for {config_id}"); + + #[cfg(feature = "tokio")] + ::tokio::time::sleep(::tokio::time::Duration::from_secs( + 2_u64.pow(attempts), + )) + .await; + #[cfg(feature = "async-std")] + async_std::task::sleep(std::time::Duration::from_secs( + 2_u64.pow(attempts), + )) + .await; + #[cfg(not(any(feature = "tokio", feature = "async-std")))] + { + pending::<()>().await; + unreachable!(); + } + attempts += 1; + // The settings daemon has exited + continue; + }; + + let mut changes = changes.map(Change::Changes).fuse(); + + let Ok(owner_changed) = watcher.inner().receive_owner_changed().await + else { + tracing::error!("Failed to listen for owner changes for {config_id}"); + #[cfg(feature = "tokio")] + ::tokio::time::sleep(::tokio::time::Duration::from_secs( + 2_u64.pow(attempts), + )) + .await; + #[cfg(feature = "async-std")] + async_std::task::sleep(std::time::Duration::from_secs( + 2_u64.pow(attempts), + )) + .await; + #[cfg(not(any(feature = "tokio", feature = "async-std")))] + { + pending::<()>().await; + unreachable!(); + } + attempts += 1; + // The settings daemon has exited + continue; + }; + let mut owner_changed = owner_changed + .map(|c| Change::OwnerChanged(c.is_some())) + .fuse(); + + // update now, just in case we missed changes while setting up stream + let mut config = match T::get_entry(&cosmic_config) { + Ok(config) => config, + Err((errors, default)) => { + for why in &errors { + if why.is_err() { + if let crate::Error::GetKey(_, err) = &why { + if err.kind() == std::io::ErrorKind::NotFound { + // No system default config installed; don't error + continue; + } + } + tracing::error!("error getting config: {config_id} {why}"); + } + } + default + } + }; + + if let Err(err) = tx + .send(Update { + errors: Vec::new(), + keys: Vec::new(), + config: config.clone(), + }) + .await + { + tracing::error!("Failed to send config: {err}"); + } + + loop { + let change: Changed = futures::select! { + c = changes.next() => { + let Some(Change::Changes(c)) = c else { + break; + }; + c + } + c = owner_changed.next() => { + let Some(Change::OwnerChanged(cont)) = c else { + break; + }; + if cont { + continue; + } else { + // The settings daemon has exited + break; + } + }, + }; + + // Reset the attempts counter if we received a change + attempts = 0; + let Ok(args) = change.args() else { + // The settings daemon has exited + break; + }; + let (errors, keys) = config.update_keys(&cosmic_config, &[args.key]); + if !keys.is_empty() { + if let Err(err) = tx + .send(Update { + errors, + keys, + config: config.clone(), + }) + .await + { + tracing::error!("Failed to send config update: {err}"); + } + } + } + } + }, + ) + }, + ) +} diff --git a/cosmic-config/src/lib.rs b/cosmic-config/src/lib.rs index 8ff3d8db..c8eda064 100644 --- a/cosmic-config/src/lib.rs +++ b/cosmic-config/src/lib.rs @@ -1,20 +1,67 @@ -use iced_futures::futures::SinkExt; -#[cfg(feature = "subscription")] -use iced_futures::{futures::channel::mpsc, subscription}; +//! Integrations for cosmic-config — the cosmic configuration system. + use notify::{ - event::{EventKind, ModifyKind}, RecommendedWatcher, Watcher, + event::{EventKind, ModifyKind, RenameMode}, }; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{Serialize, de::DeserializeOwned}; use std::{ - borrow::Cow, - fmt, fs, - hash::Hash, + env, fmt, fs, io::Write, path::{Path, PathBuf}, sync::Mutex, }; +/// Get the config directory, with Flatpak sandbox support. +/// In Flatpak, HOST_XDG_CONFIG_HOME points to the real user config directory, +/// allowing sandboxed apps to read host config files. +fn get_config_dir() -> Option { + // Check if we're running in Flatpak + if let Some(flatpak_id) = env::var_os("FLATPAK_ID") { + tracing::debug!("Running in Flatpak: {:?}", flatpak_id); + // Try HOST_XDG_CONFIG_HOME first (requires --filesystem=xdg-config permission) + if let Some(host_config) = env::var_os("HOST_XDG_CONFIG_HOME") { + tracing::debug!("Using HOST_XDG_CONFIG_HOME: {:?}", host_config); + return Some(PathBuf::from(host_config)); + } + // Fallback: try to construct from HOME (which points to real home in Flatpak) + if let Some(home) = env::var_os("HOME") { + let config_path = PathBuf::from(&home).join(".config"); + tracing::debug!("Using HOME fallback for config: {:?}", config_path); + return Some(config_path); + } + tracing::warn!("Flatpak detected but no config directory found"); + } + // Not in Flatpak or no host config available, use standard dirs + let config_dir = dirs::config_dir(); + tracing::debug!("Using standard config dir: {:?}", config_dir); + config_dir +} + +/// Get the state directory, with Flatpak sandbox support. +fn get_state_dir() -> Option { + // Check if we're running in Flatpak + if env::var_os("FLATPAK_ID").is_some() { + // Try HOST_XDG_STATE_HOME first + if let Some(host_state) = env::var_os("HOST_XDG_STATE_HOME") { + return Some(PathBuf::from(host_state)); + } + // Fallback: try to construct from HOME + if let Some(home) = env::var_os("HOME") { + return Some(PathBuf::from(home).join(".local").join("state")); + } + } + dirs::state_dir() +} + +#[cfg(feature = "subscription")] +mod subscription; +#[cfg(feature = "subscription")] +pub use subscription::*; + +#[cfg(all(feature = "dbus", feature = "subscription"))] +pub mod dbus; + #[cfg(feature = "macro")] pub use cosmic_config_derive; @@ -28,11 +75,14 @@ pub enum Error { Io(std::io::Error), NoConfigDirectory, Notify(notify::Error), + NotFound, Ron(ron::Error), RonSpanned(ron::error::SpannedError), + GetKey(String, std::io::Error), } impl fmt::Display for Error { + #[cold] fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::AtomicWrites(err) => err.fmt(f), @@ -40,14 +90,26 @@ impl fmt::Display for Error { Self::Io(err) => err.fmt(f), Self::NoConfigDirectory => write!(f, "cosmic config directory not found"), Self::Notify(err) => err.fmt(f), + Self::NotFound => write!(f, "cosmic config key not configured"), Self::Ron(err) => err.fmt(f), Self::RonSpanned(err) => err.fmt(f), + Self::GetKey(key, err) => write!(f, "failed to get key '{}': {}", key, err), } } } impl std::error::Error for Error {} +impl Error { + /// Whether the reason for the missing config is caused by an error. + /// + /// Useful for determining if it is appropriate to log as an error. + #[inline] + pub fn is_err(&self) -> bool { + !matches!(self, Self::NoConfigDirectory | Self::NotFound) + } +} + impl From> for Error { fn from(f: atomicwrites::Error) -> Self { Self::AtomicWrites(f) @@ -80,7 +142,15 @@ impl From for Error { pub trait ConfigGet { /// Get a configuration value + /// + /// Fallback to the system default if a local user override is not defined. fn get(&self, key: &str) -> Result; + + /// Get a locally-defined configuration value from the user's local config. + fn get_local(&self, key: &str) -> Result; + + /// Get the system-defined default configuration value. + fn get_system_default(&self, key: &str) -> Result; } pub trait ConfigSet { @@ -90,47 +160,88 @@ pub trait ConfigSet { #[derive(Clone, Debug)] pub struct Config { - system_path: PathBuf, - user_path: PathBuf, + system_path: Option, + user_path: Option, +} + +/// Check that the name is relative and doesn't contain . or .. +fn sanitize_name(name: &str) -> Result<&Path, Error> { + let path = Path::new(name); + if path + .components() + .all(|x| matches!(x, std::path::Component::Normal(_))) + { + Ok(path) + } else { + Err(Error::InvalidName(name.to_owned())) + } } impl Config { - /// Get the config for the libcosmic toolkit - pub fn libcosmic() -> Result { - Self::new("com.system76.libcosmic", 1) + /// Get a system config for the given name and config version + pub fn system(name: &str, version: u64) -> Result { + let path = sanitize_name(name)?.join(format!("v{version}")); + #[cfg(unix)] + let system_path = xdg::BaseDirectories::with_prefix("cosmic").find_data_file(path); + + #[cfg(windows)] + let system_path = + known_folders::get_known_folder_path(known_folders::KnownFolder::ProgramFilesCommon) + .map(|x| x.join("COSMIC").join(&path)); + + Ok(Self { + system_path, + user_path: None, + }) } /// Get config for the given application name and config version // Use folder at XDG config/name for config storage, return Config if successful //TODO: fallbacks for flatpak (HOST_XDG_CONFIG_HOME, xdg-desktop settings proxy) pub fn new(name: &str, version: u64) -> Result { - // Get libcosmic system defaults path - //TODO: support non-UNIX OS - let cosmic_system_path = Path::new("/usr/share/cosmic"); - // Append [name]/v[version] - let system_path = cosmic_system_path.join(name).join(format!("v{}", version)); + // Look for [name]/v[version] + let path = sanitize_name(name)?.join(format!("v{}", version)); + + // Search data file, which provides default (e.g. /usr/share) + #[cfg(unix)] + let system_path = xdg::BaseDirectories::with_prefix("cosmic").find_data_file(&path); + + #[cfg(windows)] + let system_path = + known_folders::get_known_folder_path(known_folders::KnownFolder::ProgramFilesCommon) + .map(|x| x.join("COSMIC").join(&path)); // Get libcosmic user configuration directory - let cosmic_user_path = dirs::config_dir() - .ok_or(Error::NoConfigDirectory)? - .join("cosmic"); - // Append [name]/v[version] - let user_path = cosmic_user_path.join(name).join(format!("v{}", version)); + let mut user_path = get_config_dir().ok_or(Error::NoConfigDirectory)?; + user_path.push("cosmic"); + user_path.push(path); - // If the app paths are children of the cosmic paths - if system_path.starts_with(&cosmic_system_path) && user_path.starts_with(&cosmic_user_path) - { - // Create app user path - fs::create_dir_all(&user_path)?; - // Return Config - Ok(Self { - system_path, - user_path, - }) - } else { - // Return error for invalid name - Err(Error::InvalidName(name.to_string())) - } + // Create new configuration directory if not found. + fs::create_dir_all(&user_path)?; + + // Return Config + Ok(Self { + system_path, + user_path: Some(user_path), + }) + } + + /// Get config for the given application name and config version and custom path. + pub fn with_custom_path(name: &str, version: u64, custom_path: PathBuf) -> Result { + // Look for [name]/v[version] + let path = sanitize_name(name)?.join(format!("v{version}")); + + let mut user_path = custom_path; + user_path.push("cosmic"); + user_path.push(path); + // Create new configuration directory if not found. + fs::create_dir_all(&user_path)?; + + // Return Config + Ok(Self { + system_path: None, + user_path: Some(user_path), + }) } /// Get state for the given application name and config version. State is meant to be used to @@ -139,37 +250,25 @@ impl Config { // Use folder at XDG config/name for config storage, return Config if successful //TODO: fallbacks for flatpak (HOST_XDG_CONFIG_HOME, xdg-desktop settings proxy) pub fn new_state(name: &str, version: u64) -> Result { - // Get libcosmic system defaults path - //TODO: support non-UNIX OS - let cosmic_system_path = Path::new("/var/lib/cosmic"); - // Append [name]/v[version] - let system_path = cosmic_system_path.join(name).join(format!("v{}", version)); + // Look for [name]/v[version] + let path = sanitize_name(name)?.join(format!("v{}", version)); - // Get libcosmic user configuration directory - let cosmic_user_path = dirs::state_dir() - .ok_or(Error::NoConfigDirectory)? - .join("cosmic"); - // Append [name]/v[version] - let user_path = cosmic_user_path.join(name).join(format!("v{}", version)); + // Get libcosmic user state directory + let mut user_path = get_state_dir().ok_or(Error::NoConfigDirectory)?; + user_path.push("cosmic"); + user_path.push(path); + // Create new state directory if not found. + fs::create_dir_all(&user_path)?; - // If the app paths are children of the cosmic paths - if system_path.starts_with(&cosmic_system_path) && user_path.starts_with(&cosmic_user_path) - { - // Create app user path - fs::create_dir_all(&user_path)?; - // Return Config - Ok(Self { - system_path, - user_path, - }) - } else { - // Return error for invalid name - Err(Error::InvalidName(name.to_string())) - } + Ok(Self { + system_path: None, + user_path: Some(user_path), + }) } // Start a transaction (to set multiple configs at the same time) - pub fn transaction<'a>(&'a self) -> ConfigTransaction<'a> { + #[inline] + pub fn transaction(&self) -> ConfigTransaction<'_> { ConfigTransaction { config: self, updates: Mutex::new(Vec::new()), @@ -180,20 +279,25 @@ impl Config { // This may end up being an mpsc channel instead of a function // See EventHandler in the notify crate: https://docs.rs/notify/latest/notify/trait.EventHandler.html // Having a callback allows for any application abstraction to be used - pub fn watch(&self, f: F) -> Result + pub fn watch(&self, f: F) -> Result // Argument is an array of all keys that changed in that specific transaction //TODO: simplify F requirements where F: Fn(&Self, &[String]) + Send + Sync + 'static, { let watch_config = self.clone(); + let Some(user_path) = self.user_path.as_ref() else { + return Err(Error::NoConfigDirectory); + }; + let user_path_clone = user_path.clone(); let mut watcher = notify::recommended_watcher(move |event_res: Result| { - // println!("{:#?}", event_res); - match &event_res { + match event_res { Ok(event) => { match &event.kind { - EventKind::Access(_) | EventKind::Modify(ModifyKind::Metadata(_)) => { + EventKind::Access(_) + | EventKind::Modify(ModifyKind::Metadata(_)) + | EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => { // Data not mutated return; } @@ -201,20 +305,17 @@ impl Config { } let mut keys = Vec::new(); - for path in event.paths.iter() { - match path.strip_prefix(&watch_config.user_path) { - Ok(key_path) => match key_path.to_str() { - Some(key) => { + for path in &event.paths { + match path.strip_prefix(&user_path_clone) { + Ok(key_path) => { + if let Some(key) = key_path.to_str() { // Skip any .atomicwrite temporary files if key.starts_with(".atomicwrite") { continue; } keys.push(key.to_string()); } - None => { - //TODO: handle errors - } - }, + } Err(_err) => { //TODO: handle errors } @@ -229,28 +330,24 @@ impl Config { } } })?; - watcher.watch(&self.user_path, notify::RecursiveMode::NonRecursive)?; + watcher.watch(user_path, notify::RecursiveMode::Recursive)?; Ok(watcher) } fn default_path(&self, key: &str) -> Result { - let default_path = self.system_path.join(key); - // Ensure key path is a direct child of config directory - if default_path.parent() == Some(&self.system_path) { - Ok(default_path) - } else { - Err(Error::InvalidName(key.to_string())) - } + let Some(system_path) = self.system_path.as_ref() else { + return Err(Error::NoConfigDirectory); + }; + + Ok(system_path.join(sanitize_name(key)?)) } + /// Get the path of the key in the user's local config directory. fn key_path(&self, key: &str) -> Result { - let key_path = self.user_path.join(key); - // Ensure key path is a direct child of config directory - if key_path.parent() == Some(&self.user_path) { - Ok(key_path) - } else { - Err(Error::InvalidName(key.to_string())) - } + let Some(user_path) = self.user_path.as_ref() else { + return Err(Error::NoConfigDirectory); + }; + Ok(user_path.join(sanitize_name(key)?)) } } @@ -258,18 +355,34 @@ impl Config { impl ConfigGet for Config { //TODO: check for transaction fn get(&self, key: &str) -> Result { + match self.get_local(key) { + Ok(value) => Ok(value), + Err(Error::NotFound) => self.get_system_default(key), + Err(why) => Err(why), + } + } + + fn get_local(&self, key: &str) -> Result { // If key path exists - let key_path = self.key_path(key)?; - let data = if key_path.is_file() { - // Load user override - fs::read_to_string(key_path)? - } else { - // Load system default - let default_path = self.default_path(key)?; - fs::read_to_string(default_path)? - }; - let t = ron::from_str(&data)?; - Ok(t) + match self.key_path(key) { + Ok(key_path) if key_path.is_file() => { + // Load user override + let data = fs::read_to_string(key_path) + .map_err(|err| Error::GetKey(key.to_string(), err))?; + + Ok(ron::from_str(&data)?) + } + + _ => Err(Error::NotFound), + } + } + + fn get_system_default(&self, key: &str) -> Result { + // Load system default + let default_path = self.default_path(key)?; + let data = + fs::read_to_string(default_path).map_err(|err| Error::GetKey(key.to_string(), err))?; + Ok(ron::from_str(&data)?) } } @@ -290,7 +403,7 @@ pub struct ConfigTransaction<'a> { updates: Mutex>, } -impl<'a> ConfigTransaction<'a> { +impl ConfigTransaction<'_> { /// Apply all pending changes from ConfigTransaction //TODO: apply all changes at once pub fn commit(self) -> Result<(), Error> { @@ -308,11 +421,11 @@ impl<'a> ConfigTransaction<'a> { // Setting any setting in this way will do one transaction for all settings // when commit finishes that transaction -impl<'a> ConfigSet for ConfigTransaction<'a> { +impl ConfigSet for ConfigTransaction<'_> { fn set(&self, key: &str, value: T) -> Result<(), Error> { //TODO: sanitize key (no slashes, cannot be . or ..) let key_path = self.config.key_path(key)?; - let data = ron::to_string(&value)?; + let data = ron::ser::to_string_pretty(&value, ron::ser::PrettyConfig::new())?; //TODO: replace duplicates? { let mut updates = self.updates.lock().unwrap(); @@ -322,132 +435,25 @@ impl<'a> ConfigSet for ConfigTransaction<'a> { } } -#[cfg(feature = "subscription")] -pub enum ConfigState { - Init(Cow<'static, str>, u64, bool), - Waiting(T, RecommendedWatcher, mpsc::Receiver<()>, Config), - Failed, -} - -#[cfg(feature = "subscription")] -pub enum ConfigUpdate { - Update(T), - UpdateError(T, Vec), - Failed, -} - pub trait CosmicConfigEntry where Self: Sized, { + const VERSION: u64; + fn write_entry(&self, config: &Config) -> Result<(), crate::Error>; fn get_entry(config: &Config) -> Result, Self)>; + /// Returns the keys that were updated + fn update_keys>( + &mut self, + config: &Config, + changed_keys: &[T], + ) -> (Vec, Vec<&'static str>); } -#[cfg(feature = "subscription")] -pub fn config_subscription< - I: 'static + Copy + Send + Sync + Hash, - T: 'static + Send + Sync + PartialEq + Clone + CosmicConfigEntry, ->( - id: I, - config_id: Cow<'static, str>, - config_version: u64, -) -> iced_futures::Subscription<(I, Result, T)>)> { - subscription::channel(id, 100, move |mut output| { - let config_id = config_id.clone(); - async move { - let config_id = config_id.clone(); - let mut state = ConfigState::Init(config_id, config_version, false); - - loop { - state = start_listening(state, &mut output, id).await; - } - } - }) -} - -#[cfg(feature = "subscription")] -pub fn config_state_subscription< - I: 'static + Copy + Send + Sync + Hash, - T: 'static + Send + Sync + PartialEq + Clone + CosmicConfigEntry, ->( - id: I, - config_id: Cow<'static, str>, - config_version: u64, -) -> iced_futures::Subscription<(I, Result, T)>)> { - subscription::channel(id, 100, move |mut output| { - let config_id = config_id.clone(); - async move { - let config_id = config_id.clone(); - let mut state = ConfigState::Init(config_id, config_version, true); - - loop { - state = start_listening(state, &mut output, id).await; - } - } - }) -} - -async fn start_listening< - I: Copy, - T: 'static + Send + Sync + PartialEq + Clone + CosmicConfigEntry, ->( - state: ConfigState, - output: &mut mpsc::Sender<(I, Result, T)>)>, - id: I, -) -> ConfigState { - use iced_futures::futures::{future::pending, StreamExt}; - - match state { - ConfigState::Init(config_id, version, is_state) => { - let (tx, rx) = mpsc::channel(100); - let config = match if is_state { - Config::new_state(&config_id, version) - } else { - Config::new(&config_id, version) - } { - Ok(c) => c, - Err(_) => return ConfigState::Failed, - }; - let watcher = match config.watch(move |_helper, _keys| { - let mut tx = tx.clone(); - let _ = tx.try_send(()); - }) { - Ok(w) => w, - Err(_) => return ConfigState::Failed, - }; - - match T::get_entry(&config) { - Ok(t) => { - _ = output.send((id, Ok(t.clone()))).await; - ConfigState::Waiting(t, watcher, rx, config) - } - Err((errors, t)) => { - _ = output.send((id, Err((errors, t.clone())))).await; - ConfigState::Waiting(t, watcher, rx, config) - } - } - } - ConfigState::Waiting(mut old, watcher, mut rx, config) => match rx.next().await { - Some(_) => match T::get_entry(&config) { - Ok(t) => { - if t != old { - old = t; - _ = output.send((id, Ok(old.clone()))).await; - } - ConfigState::Waiting(old, watcher, rx, config) - } - Err((errors, t)) => { - if t != old { - old = t; - _ = output.send((id, Err((errors, old.clone())))).await; - } - ConfigState::Waiting(old, watcher, rx, config) - } - }, - - None => ConfigState::Failed, - }, - ConfigState::Failed => pending().await, - } +#[derive(Debug)] +pub struct Update { + pub errors: Vec, + pub keys: Vec<&'static str>, + pub config: T, } diff --git a/cosmic-config/src/settings_daemon.rs b/cosmic-config/src/settings_daemon.rs new file mode 100644 index 00000000..e69de29b diff --git a/cosmic-config/src/subscription.rs b/cosmic-config/src/subscription.rs new file mode 100644 index 00000000..d16b9b65 --- /dev/null +++ b/cosmic-config/src/subscription.rs @@ -0,0 +1,139 @@ +use iced_futures::futures::{SinkExt, Stream}; +use iced_futures::{futures::channel::mpsc, stream}; +use notify::RecommendedWatcher; +use std::{borrow::Cow, hash::Hash}; + +use crate::{Config, CosmicConfigEntry}; + +pub enum ConfigState { + Init(Cow<'static, str>, u64, bool), + Waiting(T, RecommendedWatcher, mpsc::Receiver>, Config), + Failed, +} + +pub enum ConfigUpdate { + Update(crate::Update), + Failed, +} + +#[cold] +pub fn config_subscription< + I: 'static + Hash, + T: 'static + Send + Sync + PartialEq + Clone + CosmicConfigEntry, +>( + id: I, + config_id: Cow<'static, str>, + config_version: u64, +) -> iced_futures::Subscription> { + iced_futures::Subscription::run_with( + (id, config_id, config_version, false), + // FIXME there are type issues related to the 'static lifetime of the Cow if this is extracted to a named function... + |(_, config_id, config_version, is_state)| { + let config_id = config_id.clone(); + let config_version = *config_version; + let is_state = *is_state; + + stream::channel(100, move |mut output| async move { + let config_id = config_id.clone(); + let mut state = ConfigState::Init(config_id, config_version, is_state); + + loop { + state = start_listening::(state, &mut output).await; + } + }) + }, + ) +} + +#[cold] +pub fn config_state_subscription< + I: 'static + Hash, + T: 'static + Send + Sync + PartialEq + Clone + CosmicConfigEntry, +>( + id: I, + config_id: Cow<'static, str>, + config_version: u64, +) -> iced_futures::Subscription> { + iced_futures::Subscription::run_with( + (id, config_id, config_version, true), + |(_, config_id, config_version, is_state)| { + let config_id = config_id.clone(); + let config_version = *config_version; + let is_state = *is_state; + + stream::channel(100, move |mut output| async move { + let config_id = config_id.clone(); + let mut state = ConfigState::Init(config_id, config_version, is_state); + + loop { + state = start_listening::(state, &mut output).await; + } + }) + }, + ) +} + +async fn start_listening( + state: ConfigState, + output: &mut mpsc::Sender>, +) -> ConfigState { + use iced_futures::futures::{StreamExt, future::pending}; + + match state { + ConfigState::Init(config_id, version, is_state) => { + let (tx, rx) = mpsc::channel(100); + let Ok(config) = (if is_state { + Config::new_state(&config_id, version) + } else { + Config::new(&config_id, version) + }) else { + return ConfigState::Failed; + }; + let Ok(watcher) = config.watch(move |_helper, keys| { + let mut tx = tx.clone(); + let _ = tx.try_send(keys.to_vec()); + }) else { + return ConfigState::Failed; + }; + + match T::get_entry(&config) { + Ok(t) => { + let update = crate::Update { + errors: Vec::new(), + keys: Vec::new(), + config: t.clone(), + }; + _ = output.send(update).await; + ConfigState::Waiting(t, watcher, rx, config) + } + Err((errors, t)) => { + let update = crate::Update { + errors, + keys: Vec::new(), + config: t.clone(), + }; + _ = output.send(update).await; + ConfigState::Waiting(t, watcher, rx, config) + } + } + } + ConfigState::Waiting(mut conf_data, watcher, mut rx, config) => match rx.next().await { + Some(keys) => { + let (errors, changed) = conf_data.update_keys(&config, &keys); + + if !changed.is_empty() { + _ = output + .send(crate::Update { + errors, + keys: changed, + config: conf_data.clone(), + }) + .await; + } + ConfigState::Waiting(conf_data, watcher, rx, config) + } + None => ConfigState::Failed, + }, + ConfigState::Failed => pending().await, + } +} diff --git a/cosmic-icons b/cosmic-icons new file mode 160000 index 00000000..52520957 --- /dev/null +++ b/cosmic-icons @@ -0,0 +1 @@ +Subproject commit 5252095787cc96e2aed64604158f94e450703455 diff --git a/cosmic-theme/Cargo.toml b/cosmic-theme/Cargo.toml index 0ded5411..7e408d8d 100644 --- a/cosmic-theme/Cargo.toml +++ b/cosmic-theme/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cosmic-theme" -version = "0.1.0" -edition = "2021" +version = "1.0.0" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -10,17 +10,30 @@ features = ["test_all_features"] rustdoc-args = ["--cfg", "docsrs"] [features] -default = [] +default = ["export"] +export = ["serde_json"] no-default = [] -theme-from-image = ["kmeans_colors", "image"] [dependencies] -palette = {version = "0.7.3", features = ["serializing"] } +palette = { version = "0.7.6", features = ["serializing"] } almost = "0.2" -kmeans_colors = { version = "0.5", features = ["palette_color"], default-features = false, optional = true } -image = {version = "0.24.1", optional = true } -serde = { version = "1.0.129", features = ["derive"] } -ron = "0.8" -lazy_static = "1.4.0" -csscolorparser = {version = "0.6.2", features = ["serde"]} -cosmic-config = { path = "../cosmic-config/", default-features = false, features = ["subscription", "macro"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.149", optional = true, features = [ + "preserve_order", +] } +ron = "0.12.0" +csscolorparser = { version = "0.8.3", features = ["serde"] } +cosmic-config = { path = "../cosmic-config/", default-features = false, features = [ + "subscription", + "macro", +] } +configparser = "3.1.0" +dirs.workspace = true +thiserror = "2.0.18" + +[dev-dependencies] +insta = "1.47.2" + +[profile.dev.package] +insta.opt-level = 3 +similar.opt-level = 3 diff --git a/cosmic-theme/src/composite.rs b/cosmic-theme/src/composite.rs index c30469b2..66d7ac92 100644 --- a/cosmic-theme/src/composite.rs +++ b/cosmic-theme/src/composite.rs @@ -4,16 +4,10 @@ use palette::Srgba; pub fn over, B: Into>(a: A, b: B) -> Srgba { let a = a.into(); let b = b.into(); - let o_a = (alpha_over(a.alpha, b.alpha)).max(0.0).min(1.0); - let o_r = (c_over(a.red, b.red, a.alpha, b.alpha, o_a)) - .max(0.0) - .min(1.0); - let o_g = (c_over(a.green, b.green, a.alpha, b.alpha, o_a)) - .max(0.0) - .min(1.0); - let o_b = (c_over(a.blue, b.blue, a.alpha, b.alpha, o_a)) - .max(0.0) - .min(1.0); + let o_a = (alpha_over(a.alpha, b.alpha)).clamp(0.0, 1.0); + let o_r = (c_over(a.red, b.red, a.alpha, b.alpha, o_a)).clamp(0.0, 1.0); + let o_g = (c_over(a.green, b.green, a.alpha, b.alpha, o_a)).clamp(0.0, 1.0); + let o_b = (c_over(a.blue, b.blue, a.alpha, b.alpha, o_a)).clamp(0.0, 1.0); Srgba::new(o_r, o_g, o_b, o_a) } diff --git a/cosmic-theme/src/image.rs b/cosmic-theme/src/image.rs deleted file mode 100644 index 52a319ea..00000000 --- a/cosmic-theme/src/image.rs +++ /dev/null @@ -1 +0,0 @@ -// TODO theme from image diff --git a/cosmic-theme/src/lib.rs b/cosmic-theme/src/lib.rs index 782d15f1..5d59ccda 100644 --- a/cosmic-theme/src/lib.rs +++ b/cosmic-theme/src/lib.rs @@ -7,19 +7,18 @@ //! pub use model::*; -pub use output::*; mod model; + +#[cfg(feature = "export")] mod output; /// composite colors in srgb pub mod composite; /// get color steps pub mod steps; -/// utilities -pub mod util; /// name of cosmic theme -pub const NAME: &'static str = "com.system76.CosmicTheme"; +pub const NAME: &str = "com.system76.CosmicTheme"; pub use palette; diff --git a/cosmic-theme/src/model/cosmic_palette.rs b/cosmic-theme/src/model/cosmic_palette.rs index 61092081..3852742b 100644 --- a/cosmic-theme/src/model/cosmic_palette.rs +++ b/cosmic-theme/src/model/cosmic_palette.rs @@ -1,36 +1,32 @@ -use std::fmt; - -use lazy_static::lazy_static; use palette::Srgba; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; -use crate::util::CssColor; +/// built-in light palette +pub static LIGHT_PALETTE: LazyLock = + LazyLock::new(|| ron::from_str(include_str!("light.ron")).unwrap()); -lazy_static! { - /// built in light palette - pub static ref LIGHT_PALETTE: CosmicPalette = - ron::from_str(include_str!("light.ron")).unwrap(); - /// built in dark palette - pub static ref DARK_PALETTE: CosmicPalette = - ron::from_str(include_str!("dark.ron")).unwrap(); -} +/// built-in dark palette +pub static DARK_PALETTE: LazyLock = + LazyLock::new(|| ron::from_str(include_str!("dark.ron")).unwrap()); /// Palette type -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub enum CosmicPalette { +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub enum CosmicPalette { /// Dark mode - Dark(CosmicPaletteInner), + Dark(CosmicPaletteInner), /// Light mode - Light(CosmicPaletteInner), + Light(CosmicPaletteInner), /// High contrast light mode - HighContrastLight(CosmicPaletteInner), + HighContrastLight(CosmicPaletteInner), /// High contrast dark mode - HighContrastDark(CosmicPaletteInner), + HighContrastDark(CosmicPaletteInner), } -impl CosmicPalette { +impl CosmicPalette { /// extract the inner palette - pub fn inner(self) -> CosmicPaletteInner { + #[inline] + pub fn inner(self) -> CosmicPaletteInner { match self { CosmicPalette::Dark(p) => p, CosmicPalette::Light(p) => p, @@ -40,8 +36,9 @@ impl CosmicPalette { } } -impl AsMut> for CosmicPalette { - fn as_mut(&mut self) -> &mut CosmicPaletteInner { +impl AsMut for CosmicPalette { + #[inline] + fn as_mut(&mut self) -> &mut CosmicPaletteInner { match self { CosmicPalette::Dark(p) => p, CosmicPalette::Light(p) => p, @@ -51,11 +48,9 @@ impl AsMut> for CosmicPalette { } } -impl AsRef> for CosmicPalette -where - C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, -{ - fn as_ref(&self) -> &CosmicPaletteInner { +impl AsRef for CosmicPalette { + #[inline] + fn as_ref(&self) -> &CosmicPaletteInner { match self { CosmicPalette::Dark(p) => p, CosmicPalette::Light(p) => p, @@ -65,11 +60,9 @@ where } } -impl CosmicPalette -where - C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, -{ +impl CosmicPalette { /// check if the palette is dark + #[inline] pub fn is_dark(&self) -> bool { match self { CosmicPalette::Dark(_) | CosmicPalette::HighContrastDark(_) => true, @@ -78,6 +71,7 @@ where } /// check if the palette is high_contrast + #[inline] pub fn is_high_contrast(&self) -> bool { match self { CosmicPalette::HighContrastLight(_) | CosmicPalette::HighContrastDark(_) => true, @@ -86,157 +80,96 @@ where } } -impl Default for CosmicPalette -where - C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, -{ +impl Default for CosmicPalette { + #[inline] fn default() -> Self { CosmicPalette::Dark(Default::default()) } } /// The palette for Cosmic Theme, from which all color properties are derived -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -pub struct CosmicPaletteInner { +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +pub struct CosmicPaletteInner { /// name of the palette pub name: String, - /// basic palette - /// blue: colors used for various points of emphasis in the UI - pub blue: C, - /// red: colors used for various points of emphasis in the UI - pub red: C, - /// green: colors used for various points of emphasis in the UI - pub green: C, - /// yellow: colors used for various points of emphasis in the UI - pub yellow: C, + /// Utility Colors + /// Colors used for various points of emphasis in the UI. + pub bright_red: Srgba, + /// Colors used for various points of emphasis in the UI. + pub bright_green: Srgba, + /// Colors used for various points of emphasis in the UI. + pub bright_orange: Srgba, - /// surface grays - /// colors used for three levels of surfaces in the UI - pub gray_1: C, - /// colors used for three levels of surfaces in the UI - pub gray_2: C, - /// colors used for three levels of surfaces in the UI - pub gray_3: C, + /// Surface Grays + /// Colors used for three levels of surfaces in the UI. + pub gray_1: Srgba, + /// Colors used for three levels of surfaces in the UI. + pub gray_2: Srgba, /// System Neutrals /// A wider spread of dark colors for more general use. - pub neutral_0: C, + pub neutral_0: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_1: C, + pub neutral_1: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_2: C, + pub neutral_2: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_3: C, + pub neutral_3: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_4: C, + pub neutral_4: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_5: C, + pub neutral_5: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_6: C, + pub neutral_6: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_7: C, + pub neutral_7: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_8: C, + pub neutral_8: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_9: C, + pub neutral_9: Srgba, /// A wider spread of dark colors for more general use. - pub neutral_10: C, + pub neutral_10: Srgba, - // Utility Colors - /// Utility bright green - pub bright_green: C, - /// Utility bright red - pub bright_red: C, - /// Utility bright orange - pub bright_orange: C, + /// Potential Accent Color Combos + pub accent_blue: Srgba, + /// Potential Accent Color Combos + pub accent_indigo: Srgba, + /// Potential Accent Color Combos + pub accent_purple: Srgba, + /// Potential Accent Color Combos + pub accent_pink: Srgba, + /// Potential Accent Color Combos + pub accent_red: Srgba, + /// Potential Accent Color Combos + pub accent_orange: Srgba, + /// Potential Accent Color Combos + pub accent_yellow: Srgba, + /// Potential Accent Color Combos + pub accent_green: Srgba, + /// Potential Accent Color Combos + pub accent_warm_grey: Srgba, /// Extended Color Palette /// Colors used for themes, app icons, illustrations, and other brand purposes. - pub ext_warm_grey: C, + pub ext_warm_grey: Srgba, /// Colors used for themes, app icons, illustrations, and other brand purposes. - pub ext_orange: C, + pub ext_orange: Srgba, /// Colors used for themes, app icons, illustrations, and other brand purposes. - pub ext_yellow: C, + pub ext_yellow: Srgba, /// Colors used for themes, app icons, illustrations, and other brand purposes. - pub ext_blue: C, + pub ext_blue: Srgba, /// Colors used for themes, app icons, illustrations, and other brand purposes. - pub ext_purple: C, + pub ext_purple: Srgba, /// Colors used for themes, app icons, illustrations, and other brand purposes. - pub ext_pink: C, + pub ext_pink: Srgba, /// Colors used for themes, app icons, illustrations, and other brand purposes. - pub ext_indigo: C, - - /// Potential Accent Color Combos - pub accent_blue: C, - /// Potential Accent Color Combos - pub accent_red: C, - /// Potential Accent Color Combos - pub accent_green: C, - /// Potential Accent Color Combos - pub accent_warm_grey: C, - /// Potential Accent Color Combos - pub accent_orange: C, - /// Potential Accent Color Combos - pub accent_yellow: C, - /// Potential Accent Color Combos - pub accent_purple: C, - /// Potential Accent Color Combos - pub accent_pink: C, - /// Potential Accent Color Combos - pub accent_indigo: C, + pub ext_indigo: Srgba, } -impl From> for CosmicPaletteInner { - fn from(p: CosmicPaletteInner) -> Self { - CosmicPaletteInner { - name: p.name, - blue: p.blue.into(), - red: p.red.into(), - green: p.green.into(), - yellow: p.yellow.into(), - gray_1: p.gray_1.into(), - gray_2: p.gray_2.into(), - gray_3: p.gray_3.into(), - neutral_0: p.neutral_0.into(), - neutral_1: p.neutral_1.into(), - neutral_2: p.neutral_2.into(), - neutral_3: p.neutral_3.into(), - neutral_4: p.neutral_4.into(), - neutral_5: p.neutral_5.into(), - neutral_6: p.neutral_6.into(), - neutral_7: p.neutral_7.into(), - neutral_8: p.neutral_8.into(), - neutral_9: p.neutral_9.into(), - neutral_10: p.neutral_10.into(), - bright_green: p.bright_green.into(), - bright_red: p.bright_red.into(), - bright_orange: p.bright_orange.into(), - ext_warm_grey: p.ext_warm_grey.into(), - ext_orange: p.ext_orange.into(), - ext_yellow: p.ext_yellow.into(), - ext_blue: p.ext_blue.into(), - ext_purple: p.ext_purple.into(), - ext_pink: p.ext_pink.into(), - ext_indigo: p.ext_indigo.into(), - accent_blue: p.accent_blue.into(), - accent_red: p.accent_red.into(), - accent_green: p.accent_green.into(), - accent_warm_grey: p.accent_warm_grey.into(), - accent_orange: p.accent_orange.into(), - accent_yellow: p.accent_yellow.into(), - accent_purple: p.accent_purple.into(), - accent_pink: p.accent_pink.into(), - accent_indigo: p.accent_indigo.into(), - } - } -} - -impl CosmicPalette -where - C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, -{ +impl CosmicPalette { /// name of the palette + #[inline] pub fn name(&self) -> &str { match &self { CosmicPalette::Dark(p) => &p.name, @@ -246,14 +179,3 @@ where } } } - -impl Into> for CosmicPalette { - fn into(self) -> CosmicPalette { - match self { - CosmicPalette::Dark(p) => CosmicPalette::Dark(p.into()), - CosmicPalette::Light(p) => CosmicPalette::Light(p.into()), - CosmicPalette::HighContrastLight(p) => CosmicPalette::HighContrastLight(p.into()), - CosmicPalette::HighContrastDark(p) => CosmicPalette::HighContrastDark(p.into()), - } - } -} diff --git a/cosmic-theme/src/model/dark.ron b/cosmic-theme/src/model/dark.ron index b24cea49..4453b8bf 100644 --- a/cosmic-theme/src/model/dark.ron +++ b/cosmic-theme/src/model/dark.ron @@ -1,116 +1 @@ -Dark ( - ( - name: "cosmic-dark", - blue: ( - c: "#94EBEB", - ), - red: ( - c: "#FFB5B5", - ), - green: ( - c: "#ACF7D2", - ), - yellow: ( - c: "#FFF19E", - ), - gray_1: ( - c: "#1B1B1B", - ), - gray_2: ( - c: "#262626", - ), - gray_3: ( - c: "#303030", - ), - neutral_0: ( - c: "#000000", - ), - neutral_1: ( - c: "#1B1B1B", - ), - neutral_2: ( - c: "#303030", - ), - neutral_3: ( - c: "#474747", - ), - neutral_4: ( - c: "#5E5E5E", - ), - neutral_5: ( - c: "#777777", - ), - neutral_6: ( - c: "#919191", - ), - neutral_7: ( - c: "#ABABAB", - ), - neutral_8: ( - c: "#C6C6C6", - ), - neutral_9: ( - c: "#E2E2E2", - ), - neutral_10: ( - c: "#FFFFFF", - ), - bright_green: ( - c: "#5EDB8C", - ), - bright_red: ( - c: "#FFA090", - ), - bright_orange: ( - c: "#FFA37D", - ), - ext_warm_grey: ( - c: "#9B8E8A", - ), - ext_orange: ( - c: "#FFAD00", - ), - ext_yellow: ( - c: "#FEDB40", - ), - ext_blue: ( - c: "#48B9C7", - ), - ext_purple: ( - c: "#CF7DFF", - ), - ext_pink: ( - c: "#F93A83", - ), - ext_indigo: ( - c: "#3E88FF", - ), - accent_blue: ( - c: "#63D0DF", - ), - accent_green: ( - c: "#92CF9C", - ), - accent_warm_grey: ( - c: "#CABAB4", - ), - accent_orange: ( - c: "#FFAD00", - ), - accent_yellow: ( - c: "#F7E062", - ), - accent_purple: ( - c: "#E79CFE", - ), - accent_pink: ( - c: "#FF9CB1", - ), - accent_red: ( - c: "#FDA1A0", - ), - accent_indigo: ( - c: "#A1C0EB", - ), - ) -) +Dark((name:"cosmic-dark",bright_red:(red:1.0,green:0.62745098,blue:0.60392157,alpha:1.0),bright_green:(red:0.36862745,green:0.85882352,blue:0.54901960,alpha:1.0),bright_orange:(red:1.0,green:0.63921569,blue:0.49019608,alpha:1.0),gray_1:(red:0.10588235,green:0.10588235,blue:0.10588235,alpha:1.0),gray_2:(red:0.14901961,green:0.14901961,blue:0.14901961,alpha:1.0),neutral_0:(red:0.0,green:0.0,blue:0.0,alpha:1.0),neutral_1:(red:0.01176471,green:0.01176471,blue:0.01176471,alpha:1.0),neutral_2:(red:0.08627451,green:0.08627451,blue:0.08627451,alpha:1.0),neutral_3:(red:0.18039216,green:0.18039216,blue:0.18039216,alpha:1.0),neutral_4:(red:0.28235294,green:0.28235294,blue:0.28235294,alpha:1.0),neutral_5:(red:0.38823529,green:0.38823529,blue:0.38823529,alpha:1.0),neutral_6:(red:0.50196078,green:0.50196078,blue:0.50196078,alpha:1.0),neutral_7:(red:0.61960784,green:0.61960784,blue:0.61960784,alpha:1.0),neutral_8:(red:0.74509804,green:0.74509804,blue:0.74509804,alpha:1.0),neutral_9:(red:0.87058824,green:0.87058824,blue:0.87058824,alpha:1.0),neutral_10:(red:1.0,green:1.0,blue:1.0,alpha:1.0),accent_blue:(red:0.3882353,green:0.81568627,blue:0.87450981,alpha:1.0),accent_indigo:(red:0.63137255,green:0.75294118,blue:0.92156863,alpha:1.0),accent_purple:(red:0.90588235,green:0.61176471,blue:0.99607843,alpha:1.0),accent_pink:(red:1.0,green:0.61176471,blue:0.69411765,alpha:1.0),accent_red:(red:0.99215686,green:0.63137255,blue:0.62745098,alpha:1.0),accent_orange:(red:1.0,green:0.67843137,blue:0.0,alpha:1.0),accent_yellow:(red:0.96862745,green:0.87843137,blue:0.38431373,alpha:1.0),accent_green:(red:0.57254902,green:0.81176471,blue:0.61176471,alpha:1.0),accent_warm_grey:(red:0.79215686,green:0.72941176,blue:0.70588235,alpha:1.0),ext_warm_grey:(red:0.60784314,green:0.55686275,blue:0.54117647,alpha:1.0),ext_orange:(red:1.0,green:0.67843137,blue:0.0,alpha:1.0),ext_yellow:(red:0.99607843,green:0.85882353,blue:0.25098039,alpha:1.0),ext_blue:(red:0.28235294,green:0.72549020,blue:0.78039216,alpha:1.0),ext_purple:(red:0.81176471,green:0.49019608,blue:1.0,alpha:1.0),ext_pink:(red:0.97647059,green:0.22745098,blue:0.51372549,alpha:1.0),ext_indigo:(red:0.24313725,green:0.53333333,blue:1.0,alpha:1.0))) diff --git a/cosmic-theme/src/model/density.rs b/cosmic-theme/src/model/density.rs new file mode 100644 index 00000000..7655361c --- /dev/null +++ b/cosmic-theme/src/model/density.rs @@ -0,0 +1,69 @@ +use crate::Spacing; +use serde::{Deserialize, Serialize}; + +/// Density options for the Cosmic theme +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum Density { + /// Lower padding/spacing of elements + Compact, + /// Higher padding/spacing of elements + Spacious, + /// Standard padding/spacing of elements + #[default] + Standard, +} + +impl From for Spacing { + fn from(value: Density) -> Self { + match value { + Density::Compact => Spacing { + space_none: 0, + space_xxxs: 4, + space_xxs: 4, + space_xs: 8, + space_s: 8, + space_m: 16, + space_l: 24, + space_xl: 32, + space_xxl: 48, + space_xxxl: 64, + }, + Density::Spacious => Spacing { + space_none: 4, + space_xxxs: 8, + space_xxs: 12, + space_xs: 16, + space_s: 24, + space_m: 32, + space_l: 48, + space_xl: 64, + space_xxl: 128, + space_xxxl: 160, + }, + Density::Standard => Spacing { + space_none: 0, + space_xxxs: 4, + space_xxs: 8, + space_xs: 12, + space_s: 16, + space_m: 24, + space_l: 32, + space_xl: 48, + space_xxl: 64, + space_xxxl: 128, + }, + } + } +} + +impl From for Density { + fn from(value: Spacing) -> Self { + if value.space_m.saturating_sub(16) == 0 { + Self::Compact + } else if value.space_m.saturating_sub(24) == 0 { + Self::Standard + } else { + Self::Spacious + } + } +} diff --git a/cosmic-theme/src/model/derivation.rs b/cosmic-theme/src/model/derivation.rs index 1fd4fb8d..dce653e5 100644 --- a/cosmic-theme/src/model/derivation.rs +++ b/cosmic-theme/src/model/derivation.rs @@ -1,221 +1,203 @@ -use palette::Srgba; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use std::fmt; +use palette::{Srgba, WithAlpha}; +use serde::{Deserialize, Serialize}; use crate::composite::over; /// Theme Container colors of a theme, can be a theme background container, primary container, or secondary container -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -pub struct Container { +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[must_use] +pub struct Container { /// the color of the container - pub base: C, + pub base: Srgba, /// the color of components in the container - pub component: Component, + pub component: Component, /// the color of dividers in the container - pub divider: C, + pub divider: Srgba, /// the color of text in the container - pub on: C, + pub on: Srgba, + /// the color of @small_widget_container + pub small_widget: Srgba, } -impl Container -where - C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, -{ - /// convert to srgba - pub fn into_srgba(self) -> Container { - Container { - base: self.base.into(), - component: self.component.into_srgba(), - divider: self.divider.into(), - on: self.on.into(), - } - } +impl Container { + pub(crate) fn new( + component: Component, + base: Srgba, + on: Srgba, + mut small_widget: Srgba, + is_high_contrast: bool, + ) -> Self { + let divider_c = on.with_alpha(if is_high_contrast { 0.5 } else { 0.2 }); + small_widget.alpha = 0.25; - pub(crate) fn new(component: Component, bg: C, on_bg: C) -> Self { - let mut divider_c: Srgba = on_bg.clone().into(); - divider_c.alpha = 0.2; - - let divider = over(divider_c.clone(), bg.clone()); Self { - base: bg, + base, component, - divider: divider.into(), - on: on_bg, + divider: over(divider_c, base), + on, + small_widget, } } } /// The colors for a widget of the Cosmic theme -#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize, Eq)] -pub struct Component { +#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)] +#[must_use] +pub struct Component { /// The base color of the widget - pub base: C, + pub base: Srgba, /// The color of the widget when it is hovered - pub hover: C, + pub hover: Srgba, /// the color of the widget when it is pressed - pub pressed: C, + pub pressed: Srgba, /// the color of the widget when it is selected - pub selected: C, + pub selected: Srgba, /// the color of the widget when it is selected - pub selected_text: C, + pub selected_text: Srgba, /// the color of the widget when it is focused - pub focus: C, + pub focus: Srgba, /// the color of dividers for this widget - pub divider: C, + pub divider: Srgba, /// the color of text for this widget - pub on: C, + pub on: Srgba, // the color of text with opacity 80 for this widget - // pub text_opacity_80: C, + // pub text_opacity_80: Srgba, /// the color of the widget when it is disabled - pub disabled: C, + pub disabled: Srgba, /// the color of text in the widget when it is disabled - pub on_disabled: C, + pub on_disabled: Srgba, /// the color of the border for the widget - pub border: C, + pub border: Srgba, /// the color of the border for the widget when it is disabled - pub disabled_border: C, + pub disabled_border: Srgba, } -impl Component -where - C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, -{ +#[allow(clippy::must_use_candidate)] +#[allow(clippy::doc_markdown)] +impl Component { + #[inline] /// get @hover_state_color pub fn hover_state_color(&self) -> Srgba { - self.hover.clone().into() + self.hover } + + #[inline] /// get @pressed_state_color pub fn pressed_state_color(&self) -> Srgba { - self.pressed.clone().into() + self.pressed } + + #[inline] /// get @selected_state_color pub fn selected_state_color(&self) -> Srgba { - self.selected.clone().into() + self.selected } + + #[inline] /// get @selected_state_text_color pub fn selected_state_text_color(&self) -> Srgba { - self.selected_text.clone().into() + self.selected_text } + + #[inline] /// get @focus_color pub fn focus_color(&self) -> Srgba { - self.focus.clone().into() - } - /// convert to srgba - pub fn into_srgba(self) -> Component { - Component { - base: self.base.into(), - hover: self.hover.into(), - pressed: self.pressed.into(), - selected: self.selected.into(), - selected_text: self.selected_text.into(), - focus: self.focus.into(), - divider: self.divider.into(), - on: self.on.into(), - disabled: self.disabled.into(), - on_disabled: self.on_disabled.into(), - border: self.border.into(), - disabled_border: self.disabled_border.into(), - } + self.focus } /// helper for producing a component from a base color a neutral and an accent - pub fn colored_component(base: C, neutral: C, accent: C, hovered: C, pressed: C) -> Self { - let base: Srgba = base.into(); - let mut base_50 = base.clone(); + pub fn colored_component( + base: Srgba, + neutral: Srgba, + accent: Srgba, + hovered: Srgba, + pressed: Srgba, + ) -> Self { + let mut base_50 = base; base_50.alpha *= 0.5; - let on_20 = neutral.clone(); - let mut on_50: Srgba = on_20.clone().into(); - on_50.alpha = 0.5; + let on_20 = neutral; + let on_50 = on_20.with_alpha(0.5); Component { - base: base.clone().into(), - hover: over(hovered.clone(), base).into(), - pressed: over(pressed, base).into(), - selected: over(hovered, base).into(), - selected_text: accent.clone(), + base, + hover: over(hovered, base), + pressed: over(pressed, base), + selected: over(hovered, base), + selected_text: accent, divider: on_20, on: neutral, - disabled: over(base_50, base).into(), - on_disabled: over(on_50, base).into(), + disabled: over(base_50, base), + on_disabled: over(on_50, base), focus: accent, - border: base.into(), - disabled_border: base_50.into(), + border: base, + disabled_border: base_50, } } /// helper for producing a button component pub fn colored_button( - base: C, - overlay: C, - on_button: C, - accent: C, - hovered: C, - pressed: C, + base: Srgba, + overlay: Srgba, + on_button: Srgba, + accent: Srgba, + hovered: Srgba, + pressed: Srgba, ) -> Self { let mut component = Component::colored_component(base, overlay, accent, hovered, pressed); - component.on = on_button.clone(); + component.on = on_button; - let mut on_disabled = on_button.into(); - on_disabled.alpha = 0.5; - component.on_disabled = on_disabled.into(); + let on_disabled = on_button.with_alpha(0.5); + component.on_disabled = on_disabled; component } /// helper for producing a component color theme + #[allow(clippy::self_named_constructors)] pub fn component( - base: C, - accent: C, - on_component: C, - hovered: C, - pressed: C, + base: Srgba, + accent: Srgba, + on_component: Srgba, + hovered: Srgba, + pressed: Srgba, is_high_contrast: bool, - border: C, + border: Srgba, ) -> Self { - let base = base.into(); - let mut base_50 = base.clone(); + let mut base_50 = base; base_50.alpha *= 0.5; - let mut on_20 = on_component.clone().into(); - let mut on_50 = on_20.clone(); + let on_20 = on_component.with_alpha(0.2); + let on_65 = on_20.with_alpha(0.65); - on_20.alpha = 0.2; - on_50.alpha = 0.5; - - let border = border.into(); let mut disabled_border = border; disabled_border.alpha *= 0.5; Component { - base: base.clone().into(), + base, hover: if base.alpha < 0.001 { - hovered.clone() + hovered } else { - over(hovered.clone(), base).into() + over(hovered, base) }, pressed: if base.alpha < 0.001 { - pressed.clone() + pressed } else { - over(pressed.clone(), base).into() + over(pressed, base) }, selected: if base.alpha < 0.001 { - hovered.clone() + hovered } else { - over(hovered.clone(), base).into() + over(hovered, base) }, - selected_text: accent.clone(), - focus: accent.clone(), - divider: if is_high_contrast { - on_50.clone().into() - } else { - on_20.into() - }, - on: on_component.clone(), - disabled: over(base_50, base).into(), - on_disabled: over(on_50, base).into(), - border: border.into(), - disabled_border: disabled_border.into(), + selected_text: accent, + focus: accent, + divider: if is_high_contrast { on_65 } else { on_20 }, + on: on_component, + disabled: base_50, + on_disabled: on_65, + border, + disabled_border, } } } diff --git a/cosmic-theme/src/model/layout.rs b/cosmic-theme/src/model/layout.rs index 79456dc6..a476b630 100644 --- a/cosmic-theme/src/model/layout.rs +++ b/cosmic-theme/src/model/layout.rs @@ -1,5 +1,4 @@ #[derive(Default)] pub struct Layout { - corner_radii: [u32;4], - -} \ No newline at end of file + corner_radii: [u32; 4], +} diff --git a/cosmic-theme/src/model/light.ron b/cosmic-theme/src/model/light.ron index 07b5a64b..29b3ad65 100644 --- a/cosmic-theme/src/model/light.ron +++ b/cosmic-theme/src/model/light.ron @@ -1,116 +1 @@ -Light ( - ( - name: "cosmic-light", - blue: ( - c: "#00496D", - ), - red: ( - c: "#A0252B", - ), - green: ( - c: "#3B6E43", - ), - yellow: ( - c: "#966800", - ), - gray_1: ( - c: "#DDDDDD", - ), - gray_2: ( - c: "#E8E8E8", - ), - gray_3: ( - c: "#F3F3F3", - ), - neutral_0: ( - c: "#FFFFFF", - ), - neutral_1: ( - c: "#E2E2E2", - ), - neutral_2: ( - c: "#C6C6C6", - ), - neutral_3: ( - c: "#ABABAB", - ), - neutral_4: ( - c: "#919191", - ), - neutral_5: ( - c: "#777777", - ), - neutral_6: ( - c: "#5E5E5E", - ), - neutral_7: ( - c: "#474747", - ), - neutral_8: ( - c: "#303030", - ), - neutral_9: ( - c: "#1B1B1B", - ), - neutral_10: ( - c: "#000000", - ), - bright_green: ( - c: "#00572C", - ), - bright_red: ( - c: "#890418", - ), - bright_orange: ( - c: "#792C00", - ), - ext_warm_grey: ( - c: "#9B8E8A", - ), - ext_orange: ( - c: "#FBB86C", - ), - ext_yellow: ( - c: "#F7E062", - ), - ext_blue: ( - c: "#6ACAD8", - ), - ext_purple: ( - c: "#D58CFF", - ), - ext_pink: ( - c: "#FF9CDD", - ), - ext_indigo: ( - c: "#95C4FC", - ), - accent_blue: ( - c: "#00525A", - ), - accent_red: ( - c: "#78292E", - ), - accent_green: ( - c: "#185529", - ), - accent_warm_grey: ( - c: "#554742", - ), - accent_orange: ( - c: "#624000", - ), - accent_yellow: ( - c: "#534800", - ), - accent_purple: ( - c: "#68217C", - ), - accent_pink: ( - c: "#86043A", - ), - accent_indigo: ( - c: "#2E496D", - ), - ) -) +Light((name:"cosmic-light",bright_red:(red:0.53725490,green:0.01568627,blue:0.09411765,alpha:1.0),bright_green:(red:0.0,green:0.34117647,blue:0.17254901,alpha:1.0),bright_orange:(red:0.47450980,green:0.17254902,blue:0.0,alpha:1.0),gray_1:(red:0.84313725,green:0.84313725,blue:0.84313725,alpha:1.0),gray_2:(red:0.89411765,green:0.89411765,blue:0.89411765,alpha:1.0),neutral_0:(red:1.0,green:1.0,blue:1.0,alpha:1.0),neutral_1:(red:0.87058824,green:0.87058824,blue:0.87058824,alpha:1.0),neutral_2:(red:0.74509804,green:0.74509804,blue:0.74509804,alpha:1.0),neutral_3:(red:0.61960784,green:0.61960784,blue:0.61960784,alpha:1.0),neutral_4:(red:0.50196078,green:0.50196078,blue:0.50196078,alpha:1.0),neutral_5:(red:0.38823529,green:0.38823529,blue:0.38823529,alpha:1.0),neutral_6:(red:0.28235294,green:0.28235294,blue:0.28235294,alpha:1.0),neutral_7:(red:0.18039216,green:0.18039216,blue:0.18039216,alpha:1.0),neutral_8:(red:0.08627451,green:0.08627451,blue:0.08627451,alpha:1.0),neutral_9:(red:0.01176471,green:0.01176471,blue:0.01176471,alpha:1.0),neutral_10:(red:0.0,green:0.0,blue:0.0,alpha:1.0),accent_blue:(red:0.0,green:0.32156863,blue:0.35294118,alpha:1.0),accent_indigo:(red:0.18039216,green:0.28627451,blue:0.42745098,alpha:1.0),accent_purple:(red:0.40784314,green:0.12941176,blue:0.48627451,alpha:1.0),accent_pink:(red:0.52549020,green:0.01568627,blue:0.22745098,alpha:1.0),accent_red:(red:0.47058824,green:0.16078431,blue:0.18039216,alpha:1.0),accent_orange:(red:0.38431373,green:0.25098039,blue:0.0,alpha:1.0),accent_yellow:(red:0.32549020,green:0.28235294,blue:0.0,alpha:1.0),accent_green:(red:0.09411765,green:0.33333333,blue:0.16078431,alpha:1.0),accent_warm_grey:(red:0.33333333,green:0.27843137,blue:0.25882353,alpha:1.0),ext_warm_grey:(red:0.60784314,green:0.55686275,blue:0.54117647,alpha:1.0),ext_orange:(red:0.98431373,green:0.72156863,blue:0.42352941,alpha:1.0),ext_yellow:(red:0.96862745,green:0.87843137,blue:0.38431373,alpha:1.0),ext_blue:(red:0.41568627,green:0.79215686,blue:0.84705882,alpha:1.0),ext_purple:(red:0.83529412,green:0.54901961,blue:1.0,alpha:1.0),ext_pink:(red:1.0,green:0.61176471,blue:0.86666667,alpha:1.0),ext_indigo:(red:0.58431373,green:0.76862745,blue:0.98823529,alpha:1.0))) diff --git a/cosmic-theme/src/model/mod.rs b/cosmic-theme/src/model/mod.rs index 19370dee..f48d1a8d 100644 --- a/cosmic-theme/src/model/mod.rs +++ b/cosmic-theme/src/model/mod.rs @@ -1,5 +1,6 @@ pub use corner::*; pub use cosmic_palette::*; +pub use density::*; pub use derivation::*; pub use mode::*; pub use spacing::*; @@ -7,6 +8,7 @@ pub use theme::*; mod corner; mod cosmic_palette; +mod density; mod derivation; mod mode; mod spacing; diff --git a/cosmic-theme/src/model/mode.rs b/cosmic-theme/src/model/mode.rs index 85853dd6..ce166979 100644 --- a/cosmic-theme/src/model/mode.rs +++ b/cosmic-theme/src/model/mode.rs @@ -1,4 +1,4 @@ -use cosmic_config::{Config, ConfigGet, ConfigSet, CosmicConfigEntry}; +use cosmic_config::{Config, ConfigGet, CosmicConfigEntry}; /// ID for the ThemeMode config pub const THEME_MODE_ID: &str = "com.system76.CosmicTheme.Mode"; @@ -7,6 +7,7 @@ pub const THEME_MODE_ID: &str = "com.system76.CosmicTheme.Mode"; #[derive( Debug, Clone, Copy, PartialEq, Eq, cosmic_config::cosmic_config_derive::CosmicConfigEntry, )] +#[version = 1] pub struct ThemeMode { /// The theme dark mode setting. pub is_dark: bool, @@ -15,6 +16,7 @@ pub struct ThemeMode { } impl Default for ThemeMode { + #[inline] fn default() -> Self { Self { is_dark: true, @@ -24,23 +26,21 @@ impl Default for ThemeMode { } impl ThemeMode { + #[inline] /// Check if the theme is currently using dark mode pub fn is_dark(config: &Config) -> Result { config.get::("is_dark") } - /// version of the theme - pub fn version() -> u64 { - 1 - } - - /// Set auto-switch from light to dark mode - pub fn set_auto_switch(config: &Config, value: bool) -> Result<(), cosmic_config::Error> { - config.set("auto_switch", value) + #[inline] + /// The current version of the theme mode config. + pub const fn version() -> u64 { + Self::VERSION } + #[inline] /// Get the config for the theme mode pub fn config() -> Result { - Config::new(THEME_MODE_ID, Self::version()) + Config::new(THEME_MODE_ID, Self::VERSION) } } diff --git a/cosmic-theme/src/model/theme.rs b/cosmic-theme/src/model/theme.rs index f0be9d6b..5db0f32c 100644 --- a/cosmic-theme/src/model/theme.rs +++ b/cosmic-theme/src/model/theme.rs @@ -1,19 +1,23 @@ use crate::{ - composite::over, steps::*, Component, Container, CornerRadii, CosmicPalette, - CosmicPaletteInner, Spacing, ThemeMode, DARK_PALETTE, LIGHT_PALETTE, NAME, + Component, Container, CornerRadii, CosmicPalette, CosmicPaletteInner, DARK_PALETTE, + LIGHT_PALETTE, NAME, Spacing, ThemeMode, + composite::over, + steps::{color_index, get_small_widget_color, get_surface_color, get_text, steps}, +}; +use cosmic_config::{Config, CosmicConfigEntry}; +use palette::{ + IntoColor, Oklcha, Srgb, Srgba, WithAlpha, color_difference::Wcag21RelativeContrast, rgb::Rgb, }; -use cosmic_config::{Config, ConfigGet, ConfigSet, CosmicConfigEntry}; -use palette::{IntoColor, Srgb, Srgba}; use serde::{Deserialize, Serialize}; use std::num::NonZeroUsize; -/// ID for the current dark ThemeBuilder config +/// ID for the current dark `ThemeBuilder` config pub const DARK_THEME_BUILDER_ID: &str = "com.system76.CosmicTheme.Dark.Builder"; /// ID for the current dark Theme config pub const DARK_THEME_ID: &str = "com.system76.CosmicTheme.Dark"; -/// ID for the current light ThemeBuilder config +/// ID for the current light `ThemeBuilder`` config pub const LIGHT_THEME_BUILDER_ID: &str = "com.system76.CosmicTheme.Light.Builder"; /// ID for the current light Theme config @@ -31,43 +35,52 @@ pub enum Layer { Secondary, } +#[must_use] /// Cosmic Theme data structure with all colors and its name -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -pub struct Theme { +#[derive( + Clone, + Debug, + Serialize, + Deserialize, + PartialEq, + cosmic_config::cosmic_config_derive::CosmicConfigEntry, +)] +#[version = 1] +pub struct Theme { /// name of the theme pub name: String, /// background element colors - pub background: Container, + pub background: Container, /// primary element colors - pub primary: Container, + pub primary: Container, /// secondary element colors - pub secondary: Container, + pub secondary: Container, /// accent element colors - pub accent: Component, + pub accent: Component, /// suggested element colors - pub success: Component, + pub success: Component, /// destructive element colors - pub destructive: Component, + pub destructive: Component, /// warning element colors - pub warning: Component, + pub warning: Component, /// accent button element colors - pub accent_button: Component, + pub accent_button: Component, /// suggested button element colors - pub success_button: Component, + pub success_button: Component, /// destructive button element colors - pub destructive_button: Component, + pub destructive_button: Component, /// warning button element colors - pub warning_button: Component, + pub warning_button: Component, /// icon button element colors - pub icon_button: Component, + pub icon_button: Component, /// link button element colors - pub link_button: Component, + pub link_button: Component, /// text button element colors - pub text_button: Component, + pub text_button: Component, /// button component styling - pub button: Component, + pub button: Component, /// palette - pub palette: CosmicPaletteInner, + pub palette: CosmicPaletteInner, /// spacing pub spacing: Spacing, /// corner radii @@ -84,153 +97,21 @@ pub struct Theme { pub window_hint: Option, /// enables blurred transparency pub is_frosted: bool, + /// shade color for dialogs + pub shade: Srgba, + /// accent text colors + /// If None, accent base color is the accent text color. + pub accent_text: Option, + /// control tint color + pub control_tint: Option, + /// text tint color + pub text_tint: Option, } -impl cosmic_config::CosmicConfigEntry for Theme { - fn write_entry(&self, config: &Config) -> Result<(), cosmic_config::Error> { - let self_ = self.clone(); - // TODO do as transaction - let tx = config.transaction(); - - tx.set("name", self_.name)?; - tx.set("background", self_.background)?; - tx.set("primary", self_.primary)?; - tx.set("secondary", self_.secondary)?; - tx.set("accent", self_.accent)?; - tx.set("success", self_.success)?; - tx.set("destructive", self_.destructive)?; - tx.set("warning", self_.warning)?; - tx.set("accent_button", self_.accent_button)?; - tx.set("success_button", self_.success_button)?; - tx.set("warning_button", self_.warning_button)?; - tx.set("destructive_button", self_.destructive_button)?; - tx.set("icon_button", self_.icon_button)?; - tx.set("link_button", self_.link_button)?; - tx.set("text_button", self_.text_button)?; - tx.set("button", self_.button)?; - tx.set("palette", self_.palette)?; - tx.set("is_dark", self_.is_dark)?; - tx.set("is_high_contrast", self_.is_high_contrast)?; - tx.set("spacing", self_.spacing)?; - tx.set("corner_radii", self_.corner_radii)?; - tx.set("active_hint", self_.active_hint)?; - tx.set("gaps", self_.gaps)?; - tx.set("window_hint", self_.window_hint)?; - - tx.commit() - } - - fn get_entry(config: &Config) -> Result, Self)> { - let mut default = Self::default(); - let mut errors = Vec::new(); - - match config.get::("name") { - Ok(name) => default.name = name, - Err(e) => errors.push(e), - } - match config.get::>("background") { - Ok(background) => default.background = background, - Err(e) => errors.push(e), - } - match config.get::>("primary") { - Ok(primary) => default.primary = primary, - Err(e) => errors.push(e), - } - match config.get::>("secondary") { - Ok(secondary) => default.secondary = secondary, - Err(e) => errors.push(e), - } - match config.get::>("accent") { - Ok(accent) => default.accent = accent, - Err(e) => errors.push(e), - } - match config.get::>("success") { - Ok(success) => default.success = success, - Err(e) => errors.push(e), - } - match config.get::>("destructive") { - Ok(destructive) => default.destructive = destructive, - Err(e) => errors.push(e), - } - match config.get::>("warning") { - Ok(warning) => default.warning = warning, - Err(e) => errors.push(e), - } - match config.get::>("success_button") { - Ok(b) => default.success_button = b, - Err(e) => errors.push(e), - } - match config.get::>("accent_button") { - Ok(b) => default.accent_button = b, - Err(e) => errors.push(e), - } - match config.get::>("destructive_button") { - Ok(b) => default.destructive_button = b, - Err(e) => errors.push(e), - } - match config.get::>("warning_button") { - Ok(warning) => default.warning_button = warning, - Err(e) => errors.push(e), - } - match config.get::>("icon_button") { - Ok(b) => default.link_button = b, - Err(e) => errors.push(e), - } - match config.get::>("link_button") { - Ok(b) => default.link_button = b, - Err(e) => errors.push(e), - } - match config.get::>("text_button") { - Ok(b) => default.text_button = b, - Err(e) => errors.push(e), - } - match config.get::>("button") { - Ok(b) => default.button = b, - Err(e) => errors.push(e), - } - match config.get::>("palette") { - Ok(palette) => default.palette = palette, - Err(e) => errors.push(e), - } - match config.get::("is_dark") { - Ok(is_dark) => default.is_dark = is_dark, - Err(e) => errors.push(e), - } - match config.get::("is_high_contrast") { - Ok(is_high_contrast) => default.is_high_contrast = is_high_contrast, - Err(e) => errors.push(e), - } - match config.get::("spacing") { - Ok(spacing) => default.spacing = spacing, - Err(e) => errors.push(e), - } - match config.get::("corner_radii") { - Ok(corner_radii) => default.corner_radii = corner_radii, - Err(e) => errors.push(e), - } - match config.get::("active_hint") { - Ok(active_hint) => default.active_hint = active_hint, - Err(e) => errors.push(e), - } - match config.get::<(u32, u32)>("gaps") { - Ok(gaps) => default.gaps = gaps, - Err(e) => errors.push(e), - } - match config.get::>("window_hint") { - Ok(window_hint) => default.window_hint = window_hint, - Err(e) => errors.push(e), - } - if errors.is_empty() { - Ok(default) - } else { - Err((errors, default)) - } - } -} - -impl Default for Theme { +impl Default for Theme { + #[inline] fn default() -> Self { - Self::dark_default() + Self::preferred_theme() } } @@ -240,285 +121,650 @@ pub trait LayeredTheme { fn set_layer(&mut self, layer: Layer); } -impl Theme { - /// version of the theme - pub fn version() -> u64 { - 1 - } - +impl Theme { + #[must_use] /// id of the theme pub fn id() -> &'static str { NAME } + #[inline] /// Get the config for the current dark theme pub fn dark_config() -> Result { - Config::new(DARK_THEME_ID, Self::version()) + Config::new(DARK_THEME_ID, Self::VERSION) } + #[inline] /// Get the config for the current light theme pub fn light_config() -> Result { - Config::new(LIGHT_THEME_ID, Self::version()) + Config::new(LIGHT_THEME_ID, Self::VERSION) } -} -impl Theme { + #[inline] /// get the built in light theme pub fn light_default() -> Self { LIGHT_PALETTE.clone().into() } + #[inline] /// get the built in dark theme pub fn dark_default() -> Self { DARK_PALETTE.clone().into() } + #[inline] /// get the built in high contrast dark theme pub fn high_contrast_dark_default() -> Self { CosmicPalette::HighContrastDark(DARK_PALETTE.as_ref().clone()).into() } + #[inline] /// get the built in high contrast light theme pub fn high_contrast_light_default() -> Self { CosmicPalette::HighContrastLight(LIGHT_PALETTE.as_ref().clone()).into() } + #[inline] /// Convert the theme to a high-contrast variant pub fn to_high_contrast(&self) -> Self { todo!(); } + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_0 color + pub fn control_0(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_0) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_1 color + pub fn control_1(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_1) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_2 color + pub fn control_2(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_2) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_3 color + pub fn control_3(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_3) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_3 color + pub fn control_4(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_4) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_3 color + pub fn control_5(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_5) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_3 color + pub fn control_6(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_6) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_3 color + pub fn control_7(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_7) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_3 color + pub fn control_8(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_8) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_3 color + pub fn control_9(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_9) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get control_3 color + pub fn control_10(&self) -> Srgba { + self.tint_neutral(self.palette.neutral_10) + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get @accent_color + fn tint_neutral(&self, neutral: Srgba) -> Srgba { + let Some(tint) = self.control_tint else { + return neutral; + }; + let mut oklch_neutral: Oklcha = neutral.into_color(); + let oklch_tint: Oklcha = tint.into_color(); + oklch_neutral.hue = oklch_tint.hue; + oklch_neutral.chroma = oklch_tint.chroma; + oklch_neutral.into_color() + } + // TODO convenient getter functions for each named color variable + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @accent_color pub fn accent_color(&self) -> Srgba { - self.accent.base.clone() + self.accent.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @success_color pub fn success_color(&self) -> Srgba { - self.success.base.clone() + self.success.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @destructive_color pub fn destructive_color(&self) -> Srgba { - self.destructive.base.clone() + self.destructive.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @warning_color pub fn warning_color(&self) -> Srgba { - self.warning.base.clone() + self.warning.base + } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get @small_widget_divider + pub fn small_widget_divider(&self) -> Srgba { + self.palette.neutral_9.with_alpha(0.2) } // Containers + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @bg_color pub fn bg_color(&self) -> Srgba { - self.background.base.clone() + self.background.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @bg_component_color pub fn bg_component_color(&self) -> Srgba { - self.background.component.base.clone() + self.background.component.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @primary_container_color pub fn primary_container_color(&self) -> Srgba { - self.primary.base.clone() + self.primary.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @primary_component_color pub fn primary_component_color(&self) -> Srgba { - self.primary.component.base.clone() + self.primary.component.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @secondary_container_color pub fn secondary_container_color(&self) -> Srgba { - self.secondary.base.clone() + self.secondary.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @secondary_component_color pub fn secondary_component_color(&self) -> Srgba { - self.secondary.component.base.clone() + self.secondary.component.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @button_bg_color pub fn button_bg_color(&self) -> Srgba { - self.button.base.clone() + self.button.base } // Text + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_bg_color pub fn on_bg_color(&self) -> Srgba { - self.background.on.clone() + self.background.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_bg_component_color pub fn on_bg_component_color(&self) -> Srgba { - self.background.component.on.clone() + self.background.component.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_primary_color pub fn on_primary_container_color(&self) -> Srgba { - self.primary.on.clone() + self.primary.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_primary_component_color pub fn on_primary_component_color(&self) -> Srgba { - self.primary.component.on.clone() + self.primary.component.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_secondary_color pub fn on_secondary_container_color(&self) -> Srgba { - self.secondary.on.clone() + self.secondary.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_secondary_component_color pub fn on_secondary_component_color(&self) -> Srgba { - self.secondary.component.on.clone() + self.secondary.component.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @accent_text_color pub fn accent_text_color(&self) -> Srgba { - self.accent.base.clone() + self.accent_text.unwrap_or(self.accent.base) } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @success_text_color pub fn success_text_color(&self) -> Srgba { - self.success.base.clone() + self.success.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @warning_text_color pub fn warning_text_color(&self) -> Srgba { - self.warning.base.clone() + self.warning.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @destructive_text_color pub fn destructive_text_color(&self) -> Srgba { - self.destructive.base.clone() + self.destructive.base } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_accent_color pub fn on_accent_color(&self) -> Srgba { - self.accent.on.clone() + self.accent.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_success_color pub fn on_success_color(&self) -> Srgba { - self.success.on.clone() + self.success.on } - /// get @oon_warning_color + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get @on_warning_color pub fn on_warning_color(&self) -> Srgba { - self.warning.on.clone() + self.warning.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @on_destructive_color pub fn on_destructive_color(&self) -> Srgba { - self.destructive.on.clone() + self.destructive.on } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @button_color pub fn button_color(&self) -> Srgba { - self.button.on.clone() + self.button.on } // Borders and Dividers + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @bg_divider pub fn bg_divider(&self) -> Srgba { - self.background.divider.clone() + self.background.divider } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @bg_component_divider pub fn bg_component_divider(&self) -> Srgba { - self.background.component.divider.clone() + self.background.component.divider } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @primary_container_divider pub fn primary_container_divider(&self) -> Srgba { - self.primary.divider.clone() + self.primary.divider } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @primary_component_divider pub fn primary_component_divider(&self) -> Srgba { - self.primary.component.divider.clone() + self.primary.component.divider } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @secondary_container_divider pub fn secondary_container_divider(&self) -> Srgba { - self.secondary.divider.clone() + self.secondary.divider } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @button_divider pub fn button_divider(&self) -> Srgba { - self.button.divider.clone() + self.button.divider } + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @window_header_bg pub fn window_header_bg(&self) -> Srgba { - self.background.base.clone() + self.background.base } + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_none pub fn space_none(&self) -> u16 { self.spacing.space_none } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_xxxs pub fn space_xxxs(&self) -> u16 { self.spacing.space_xxxs } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_xxs pub fn space_xxs(&self) -> u16 { self.spacing.space_xxs } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_xs pub fn space_xs(&self) -> u16 { self.spacing.space_xs } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_s pub fn space_s(&self) -> u16 { self.spacing.space_s } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_m pub fn space_m(&self) -> u16 { self.spacing.space_m } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_l pub fn space_l(&self) -> u16 { self.spacing.space_l } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_xl pub fn space_xl(&self) -> u16 { self.spacing.space_xl } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_xxl pub fn space_xxl(&self) -> u16 { self.spacing.space_xxl } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @space_xxxl pub fn space_xxxl(&self) -> u16 { self.spacing.space_xxxl } + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @radius_0 pub fn radius_0(&self) -> [f32; 4] { self.corner_radii.radius_0 } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @radius_xs pub fn radius_xs(&self) -> [f32; 4] { self.corner_radii.radius_xs } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @radius_s pub fn radius_s(&self) -> [f32; 4] { self.corner_radii.radius_s } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @radius_m pub fn radius_m(&self) -> [f32; 4] { self.corner_radii.radius_m } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @radius_l pub fn radius_l(&self) -> [f32; 4] { self.corner_radii.radius_l } + + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] /// get @radius_xl pub fn radius_xl(&self) -> [f32; 4] { self.corner_radii.radius_xl } - /// get the active theme + #[must_use] + #[allow(clippy::doc_markdown)] + #[inline] + /// get @shade_color + pub fn shade_color(&self) -> Srgba { + self.shade + } + + /// Get the active theme based on the current theme mode. pub fn get_active() -> Result, Self)> { - let config = - Config::new(Self::id(), Self::version()).map_err(|e| (vec![e], Self::default()))?; - let is_dark = ThemeMode::is_dark(&config).map_err(|e| (vec![e], Self::default()))?; - let config = if is_dark { - Self::dark_config() + (|| { + (if ThemeMode::is_dark(&Config::new(Self::id(), Self::VERSION)?)? { + Self::dark_config + } else { + Self::light_config + })() + })() + .map_err(|error| (vec![error], Self::default())) + .and_then(|theme_config| Self::get_entry(&theme_config)) + } + + #[must_use] + /// Rebuild the current theme with the provided accent + pub fn with_accent(&self, c: Srgba) -> Self { + let mut oklcha: Oklcha = c.into_color(); + let cur_oklcha: Oklcha = self.accent_color().into_color(); + oklcha.l = cur_oklcha.l; + let adjusted_c: Srgb = oklcha.into_color(); + + let is_dark = self.is_dark; + + let mut builder = if is_dark { + ThemeBuilder::dark_config() + .ok() + .and_then(|h| ThemeBuilder::get_entry(&h).ok()) + .unwrap_or_else(ThemeBuilder::dark) } else { - Self::light_config() + ThemeBuilder::light_config() + .ok() + .and_then(|h| ThemeBuilder::get_entry(&h).ok()) + .unwrap_or_else(ThemeBuilder::light) + }; + builder = builder.accent(adjusted_c); + builder.build() + } + + /// choose default color palette based on preferred GTK color scheme + pub fn gtk_prefer_colorscheme() -> Self { + let gsettings = "/usr/bin/gsettings"; + + let cmd = std::process::Command::new(gsettings) + .arg("get") + .arg("org.gnome.desktop.interface") + .arg("color-scheme") + .output(); + + if let Ok(cmd) = cmd { + let color_scheme = String::from_utf8_lossy(&cmd.stdout); + + if color_scheme.trim().contains("default") || color_scheme.trim().contains("light") { + return Self::light_default(); + } + }; + + Self::dark_default() + } + + /// check current desktop environment and preferred color scheme and set it as default + pub fn preferred_theme() -> Self { + let current_desktop = std::env::var("XDG_CURRENT_DESKTOP"); + + if let Ok(desktop) = current_desktop { + if desktop.trim().to_lowercase().contains("gnome") { + return Self::gtk_prefer_colorscheme(); + } } - .map_err(|e| (vec![e], Self::default()))?; - Self::get_entry(&config) + + Self::dark_default() } } -impl From> for Theme -where - CosmicPalette: Into>, -{ - fn from(p: CosmicPalette) -> Self { - ThemeBuilder::palette(p.into()).build() +impl From for Theme { + fn from(p: CosmicPalette) -> Self { + ThemeBuilder::palette(p).build() } } +#[must_use] /// Helper for building customized themes #[derive( Clone, @@ -528,9 +774,10 @@ where cosmic_config::cosmic_config_derive::CosmicConfigEntry, PartialEq, )] +#[version = 1] pub struct ThemeBuilder { /// override the palette for the builder - pub palette: CosmicPalette, + pub palette: CosmicPalette, /// override spacing for the builder pub spacing: Spacing, /// override corner radii for the builder @@ -566,7 +813,7 @@ pub struct ThemeBuilder { impl Default for ThemeBuilder { fn default() -> Self { Self { - palette: DARK_PALETTE.to_owned().into(), + palette: DARK_PALETTE.to_owned(), spacing: Spacing::default(), corner_radii: CornerRadii::default(), neutral_tint: Default::default(), @@ -580,120 +827,136 @@ impl Default for ThemeBuilder { destructive: Default::default(), is_frosted: false, // cosmic-comp theme settings - gaps: (0, 4), - active_hint: 4, + gaps: (0, 8), + active_hint: 3, window_hint: None, } } } impl ThemeBuilder { + #[inline] /// Get a builder that is initialized with the default dark theme pub fn dark() -> Self { Self { - palette: DARK_PALETTE.to_owned().into(), + palette: DARK_PALETTE.to_owned(), ..Default::default() } } + #[inline] /// Get a builder that is initialized with the default light theme pub fn light() -> Self { Self { - palette: LIGHT_PALETTE.to_owned().into(), + palette: LIGHT_PALETTE.to_owned(), ..Default::default() } } + #[inline] /// Get a builder that is initialized with the default dark high contrast theme pub fn dark_high_contrast() -> Self { - let palette: CosmicPalette = DARK_PALETTE.to_owned().into(); + let palette: CosmicPalette = DARK_PALETTE.to_owned(); Self { palette: CosmicPalette::HighContrastDark(palette.inner()), ..Default::default() } } + #[inline] /// Get a builder that is initialized with the default light high contrast theme pub fn light_high_contrast() -> Self { - let palette: CosmicPalette = LIGHT_PALETTE.to_owned().into(); + let palette: CosmicPalette = LIGHT_PALETTE.to_owned(); Self { palette: CosmicPalette::HighContrastLight(palette.inner()), ..Default::default() } } + #[inline] /// Get a builder that is initialized with the provided palette - pub fn palette(palette: CosmicPalette) -> Self { + pub fn palette(palette: CosmicPalette) -> Self { Self { palette, ..Default::default() } } + #[inline] /// set the spacing of the builder pub fn spacing(mut self, spacing: Spacing) -> Self { self.spacing = spacing; self } - /// set the corner_radii of the builder + #[inline] + /// set the corner radii of the builder pub fn corner_radii(mut self, corner_radii: CornerRadii) -> Self { self.corner_radii = corner_radii; self } + #[inline] /// apply a neutral tint to the palette pub fn neutral_tint(mut self, tint: Srgb) -> Self { self.neutral_tint = Some(tint); self } + #[inline] /// apply a text tint to the palette pub fn text_tint(mut self, tint: Srgb) -> Self { self.text_tint = Some(tint); self } + #[inline] /// apply a background color to the palette pub fn bg_color(mut self, c: Srgba) -> Self { self.bg_color = Some(c); self } + #[inline] /// apply a primary container background color to the palette pub fn primary_container_bg(mut self, c: Srgba) -> Self { self.primary_container_bg = Some(c); self } + #[inline] /// apply a accent color to the palette pub fn accent(mut self, c: Srgb) -> Self { self.accent = Some(c); self } + #[inline] /// apply a success color to the palette pub fn success(mut self, c: Srgb) -> Self { self.success = Some(c); self } + #[inline] /// apply a warning color to the palette pub fn warning(mut self, c: Srgb) -> Self { self.warning = Some(c); self } + #[inline] /// apply a destructive color to the palette pub fn destructive(mut self, c: Srgb) -> Self { self.destructive = Some(c); self } + #[allow(clippy::too_many_lines)] /// build the theme - pub fn build(self) -> Theme { + pub fn build(self) -> Theme { let Self { - mut palette, + palette, spacing, corner_radii, neutral_tint, @@ -717,200 +980,264 @@ impl ThemeBuilder { let accent = if let Some(accent) = accent { accent.into_color() } else { - palette.as_ref().blue.to_owned() + palette.as_ref().accent_blue }; let success = if let Some(success) = success { success.into_color() } else { - palette.as_ref().green.to_owned() + palette.as_ref().bright_green }; let warning = if let Some(warning) = warning { warning.into_color() } else { - palette.as_ref().yellow.to_owned() + palette.as_ref().bright_orange }; let destructive = if let Some(destructive) = destructive { destructive.into_color() } else { - palette.as_ref().red.to_owned() + palette.as_ref().bright_red }; let text_steps_array = text_tint.map(|c| steps(c, NonZeroUsize::new(100).unwrap())); - if let Some(neutral_tint) = neutral_tint { - let mut neutral_steps_arr = steps(neutral_tint, NonZeroUsize::new(11).unwrap()); - if !is_dark { - neutral_steps_arr.reverse(); - } - - let p = palette.as_mut(); - p.neutral_0 = neutral_steps_arr[0]; - p.neutral_1 = neutral_steps_arr[1]; - p.neutral_2 = neutral_steps_arr[2]; - p.neutral_3 = neutral_steps_arr[3]; - p.neutral_4 = neutral_steps_arr[4]; - p.neutral_5 = neutral_steps_arr[5]; - p.neutral_6 = neutral_steps_arr[6]; - p.neutral_7 = neutral_steps_arr[7]; - p.neutral_8 = neutral_steps_arr[8]; - p.neutral_9 = neutral_steps_arr[9]; - p.neutral_10 = neutral_steps_arr[10]; + let mut control_steps_array = if let Some(neutral_tint) = neutral_tint { + steps(neutral_tint, NonZeroUsize::new(11).unwrap()) + } else { + steps(palette.as_ref().neutral_2, NonZeroUsize::new(11).unwrap()) + }; + if !is_dark { + control_steps_array.reverse(); } let p_ref = palette.as_ref(); + let neutral_steps = steps( + neutral_tint.unwrap_or(Rgb::new(0.0, 0.0, 0.0)), + NonZeroUsize::new(100).unwrap(), + ); + let bg = if let Some(bg_color) = bg_color { bg_color } else { - p_ref.gray_1.clone() + p_ref.gray_1 }; let step_array = steps(bg, NonZeroUsize::new(100).unwrap()); let bg_index = color_index(bg, step_array.len()); - let primary_container_bg = if let Some(primary_container_bg_color) = primary_container_bg { - primary_container_bg_color + let mut component_hovered_overlay = if bg_index < 91 { + control_steps_array[10] } else { - get_surface_color(bg_index, 5, &step_array, is_dark, &p_ref.neutral_1) + control_steps_array[0] }; + component_hovered_overlay.alpha = 0.1; - let secondary_container_bg = if let Some(secondary_container_bg) = secondary_container_bg { - secondary_container_bg - } else { - get_surface_color(bg_index, 10, &step_array, is_dark, &p_ref.neutral_2) - }; + let mut component_pressed_overlay = component_hovered_overlay; + component_pressed_overlay.alpha = 0.2; + + // Standard button background is neutral 7 with 25% opacity + let button_bg = control_steps_array[7].with_alpha(0.25); + + let (button_hovered_overlay, button_pressed_overlay) = ( + control_steps_array[5].with_alpha(0.2), + control_steps_array[2].with_alpha(0.5), + ); let bg_component = get_surface_color(bg_index, 8, &step_array, is_dark, &p_ref.neutral_2); let on_bg_component = get_text( color_index(bg_component, step_array.len()), &step_array, - is_dark, - &p_ref.neutral_8, - text_steps_array.as_ref(), + &control_steps_array[8], + text_steps_array.as_deref(), ); - let mut component_hovered_overlay = p_ref.neutral_0.clone(); - component_hovered_overlay.alpha = 0.1; + let primary = { + let container_bg = if let Some(primary_container_bg_color) = primary_container_bg { + primary_container_bg_color + } else { + get_surface_color(bg_index, 5, &step_array, is_dark, &control_steps_array[1]) + }; - let mut component_pressed_overlay = p_ref.neutral_0.clone(); - component_pressed_overlay.alpha = 0.2; + let step_array = steps(container_bg, NonZeroUsize::new(100).unwrap()); + let base_index: usize = color_index(container_bg, step_array.len()); + let component_base = + get_surface_color(base_index, 6, &step_array, is_dark, &control_steps_array[3]); - let bg_component = Component::component( - bg_component, - accent, - on_bg_component, - component_hovered_overlay, - component_pressed_overlay, - is_high_contrast, - p_ref.neutral_8, - ); + component_hovered_overlay = if base_index < 91 { + control_steps_array[10] + } else { + control_steps_array[0] + }; + component_hovered_overlay.alpha = 0.1; - let primary_index = color_index(primary_container_bg, step_array.len()); - let primary_component = - get_surface_color(primary_index, 6, &step_array, is_dark, &p_ref.neutral_3); - let on_primary_component = get_text( - color_index(primary_component, step_array.len()), - &step_array, - is_dark, - &p_ref.neutral_8, - text_steps_array.as_ref(), - ); - let primary_component = Component::component( - primary_component, - accent, - on_primary_component, - component_hovered_overlay, - component_pressed_overlay, - is_high_contrast, - p_ref.neutral_8, - ); + component_pressed_overlay = component_hovered_overlay; + component_pressed_overlay.alpha = 0.2; - let secondary_index = color_index(secondary_container_bg, step_array.len()); - let secondary_component = - get_surface_color(secondary_index, 3, &step_array, is_dark, &p_ref.neutral_4); - let on_secondary_component = get_text( - color_index(secondary_component, step_array.len()), - &step_array, - is_dark, - &p_ref.neutral_8, - text_steps_array.as_ref(), - ); - let secondary_component = Component::component( - secondary_component, - accent, - on_secondary_component, - component_hovered_overlay, - component_pressed_overlay, - is_high_contrast, - p_ref.neutral_8, - ); - let neutral_7 = p_ref.neutral_7; - - // Standard button background is neutral 7 with 25% opacity - let button_bg = { - let mut color = neutral_7.clone(); - color.alpha = 0.25; - color + Container::new( + Component::component( + component_base, + accent, + get_text( + color_index(component_base, step_array.len()), + &step_array, + &control_steps_array[8], + text_steps_array.as_deref(), + ), + component_hovered_overlay, + component_pressed_overlay, + is_high_contrast, + control_steps_array[8], + ), + container_bg, + get_text( + base_index, + &step_array, + &control_steps_array[8], + text_steps_array.as_deref(), + ), + get_small_widget_color(base_index, 5, &neutral_steps, &control_steps_array[6]), + is_high_contrast, + ) }; - let (mut button_hovered_overlay, mut button_pressed_overlay) = - (p_ref.neutral_5, p_ref.neutral_2); - button_hovered_overlay.alpha = 0.2; - button_pressed_overlay.alpha = 0.5; + let accent_text = if is_dark { + (primary.base.relative_contrast(accent.color) < 4.).then(|| { + let step_array = steps(accent, NonZeroUsize::new(100).unwrap()); + let primary_color_index = color_index(primary.base, 100); + let steps = if is_high_contrast { 60 } else { 50 }; + let accent_text = get_surface_color( + primary_color_index, + steps, + &step_array, + is_dark, + &Srgba::new(1., 1., 1., 1.), + ); + if primary.base.relative_contrast(accent_text.color) < 4. { + Srgba::new(1., 1., 1., 1.) + } else { + accent_text + } + }) + } else { + let darkest = if bg.relative_luminance().luma < primary.base.relative_luminance().luma { + bg + } else { + primary.base + }; - let mut theme: Theme = Theme { + (darkest.relative_contrast(accent.color) < 4.).then(|| { + let step_array = steps(accent, NonZeroUsize::new(100).unwrap()); + let primary_color_index = color_index(darkest, 100); + let steps = if is_high_contrast { 60 } else { 50 }; + let accent_text = get_surface_color( + primary_color_index, + steps, + &step_array, + is_dark, + &Srgba::new(1., 1., 1., 1.), + ); + if darkest.relative_contrast(accent_text.color) < 4. { + Srgba::new(0., 0., 0., 1.) + } else { + accent_text + } + }) + }; + + let mut theme: Theme = Theme { name: palette.name().to_string(), - primary: Container::new( - primary_component, - primary_container_bg, - get_text( - primary_index, - &step_array, - is_dark, - &p_ref.neutral_8, - text_steps_array.as_ref(), + shade: if palette.is_dark() { + Srgba::new(0., 0., 0., 0.32) + } else { + Srgba::new(0., 0., 0., 0.08) + }, + background: Container::new( + Component::component( + bg_component, + accent, + on_bg_component, + component_hovered_overlay, + component_pressed_overlay, + is_high_contrast, + control_steps_array[8], ), - ), - secondary: Container::new( - secondary_component, - secondary_container_bg, + bg, get_text( - secondary_index, + bg_index, &step_array, - is_dark, - &p_ref.neutral_8, - text_steps_array.as_ref(), + &control_steps_array[8], + text_steps_array.as_deref(), ), + get_small_widget_color(bg_index, 5, &neutral_steps, &control_steps_array[6]), + is_high_contrast, ), + primary, + secondary: { + let container_bg = if let Some(secondary_container_bg) = secondary_container_bg { + secondary_container_bg + } else { + get_surface_color(bg_index, 10, &step_array, is_dark, &control_steps_array[2]) + }; + + let step_array = steps(container_bg, NonZeroUsize::new(100).unwrap()); + let base_index = color_index(container_bg, step_array.len()); + let secondary_component = + get_surface_color(base_index, 3, &step_array, is_dark, &control_steps_array[4]); + + component_hovered_overlay = if base_index < 91 { + control_steps_array[10] + } else { + control_steps_array[0] + }; + component_hovered_overlay.alpha = 0.1; + + component_pressed_overlay = component_hovered_overlay; + component_pressed_overlay.alpha = 0.2; + + Container::new( + Component::component( + secondary_component, + accent, + get_text( + color_index(secondary_component, step_array.len()), + &step_array, + &control_steps_array[8], + text_steps_array.as_deref(), + ), + component_hovered_overlay, + component_pressed_overlay, + is_high_contrast, + control_steps_array[8], + ), + container_bg, + get_text( + base_index, + &step_array, + &control_steps_array[8], + text_steps_array.as_deref(), + ), + get_small_widget_color(base_index, 5, &neutral_steps, &control_steps_array[6]), + is_high_contrast, + ) + }, accent: Component::colored_component( accent, - p_ref.neutral_0, + control_steps_array[0], accent, button_hovered_overlay, button_pressed_overlay, ), accent_button: Component::colored_button( accent, - p_ref.neutral_1, - p_ref.neutral_0, + control_steps_array[1], + control_steps_array[0], accent, button_hovered_overlay, button_pressed_overlay, ), - background: Container::new( - bg_component, - bg, - get_text( - bg_index, - &step_array, - is_dark, - &p_ref.neutral_8, - text_steps_array.as_ref(), - ), - ), button: Component::component( button_bg, accent, @@ -918,19 +1245,19 @@ impl ThemeBuilder { button_hovered_overlay, button_pressed_overlay, is_high_contrast, - p_ref.neutral_8, + control_steps_array[8], ), destructive: Component::colored_component( destructive, - p_ref.neutral_0, + control_steps_array[0], accent, button_hovered_overlay, button_pressed_overlay, ), destructive_button: Component::colored_button( destructive, - p_ref.neutral_1, - p_ref.neutral_0, + control_steps_array[1], + control_steps_array[0], accent, button_hovered_overlay, button_pressed_overlay, @@ -938,40 +1265,37 @@ impl ThemeBuilder { icon_button: Component::component( Srgba::new(0.0, 0.0, 0.0, 0.0), accent, - p_ref.neutral_8, + control_steps_array[8], button_hovered_overlay, button_pressed_overlay, is_high_contrast, - p_ref.neutral_8, + control_steps_array[8], ), link_button: { let mut component = Component::component( Srgba::new(0.0, 0.0, 0.0, 0.0), accent, - accent, + accent_text.unwrap_or(accent), Srgba::new(0.0, 0.0, 0.0, 0.0), Srgba::new(0.0, 0.0, 0.0, 0.0), is_high_contrast, - p_ref.neutral_8, + control_steps_array[8], ); - let mut on_50 = component.on.clone(); - on_50.alpha = 0.5; - - component.on_disabled = over(on_50, component.base); + component.on_disabled = over(component.on.with_alpha(0.5), component.base); component }, success: Component::colored_component( success, - p_ref.neutral_0, + control_steps_array[0], accent, button_hovered_overlay, button_pressed_overlay, ), success_button: Component::colored_button( success, - p_ref.neutral_1, - p_ref.neutral_0, + control_steps_array[1], + control_steps_array[0], accent, button_hovered_overlay, button_pressed_overlay, @@ -979,23 +1303,23 @@ impl ThemeBuilder { text_button: Component::component( Srgba::new(0.0, 0.0, 0.0, 0.0), accent, - accent, + accent_text.unwrap_or(accent), button_hovered_overlay, button_pressed_overlay, is_high_contrast, - p_ref.neutral_8, + control_steps_array[8], ), warning: Component::colored_component( warning, - p_ref.neutral_0, + control_steps_array[0], accent, button_hovered_overlay, button_pressed_overlay, ), warning_button: Component::colored_button( warning, - p_ref.neutral_10, - p_ref.neutral_0, + control_steps_array[10], + control_steps_array[0], accent, button_hovered_overlay, button_pressed_overlay, @@ -1009,24 +1333,24 @@ impl ThemeBuilder { active_hint, window_hint, is_frosted, + accent_text, + control_tint: neutral_tint, + text_tint, }; theme.spacing = spacing; theme.corner_radii = corner_radii; theme } + #[inline] /// Get the builder for the dark config pub fn dark_config() -> Result { - Config::new(DARK_THEME_BUILDER_ID, Self::version()) + Config::new(DARK_THEME_BUILDER_ID, Self::VERSION) } + #[inline] /// Get the builder for the light config pub fn light_config() -> Result { - Config::new(LIGHT_THEME_BUILDER_ID, Self::version()) - } - - /// version of the theme builder - pub fn version() -> u64 { - 1 + Config::new(LIGHT_THEME_BUILDER_ID, Self::VERSION) } } diff --git a/cosmic-theme/src/output/gtk4_output.rs b/cosmic-theme/src/output/gtk4_output.rs index 4472f6eb..40eba5b4 100644 --- a/cosmic-theme/src/output/gtk4_output.rs +++ b/cosmic-theme/src/output/gtk4_output.rs @@ -1,187 +1,312 @@ -use crate::{ - model::{Accent, Container, ContainerType, Destructive, Widget}, - Hex, Theme, NAME, +use crate::{Component, Theme, composite::over, steps::steps}; +use palette::{Darken, IntoColor, Lighten, Srgba, WithAlpha, rgb::Rgba}; +use std::{ + fs::{self, File}, + io::{self, Write}, + num::NonZeroUsize, + path::Path, }; -use anyhow::{bail, Result}; -use palette::Srgba; -use serde::{de::DeserializeOwned, Serialize}; -use std::{fmt, fs::File, io::prelude::*, path::PathBuf}; -pub(crate) const CSS_DIR: &'static str = "css"; -pub(crate) const THEME_DIR: &'static str = "themes"; +use super::{OutputError, to_rgba}; -/// Trait for outputting the Theme variables as Gtk4CSS -pub trait Gtk4Output { +impl Theme { + #[must_use] + #[cold] /// turn the theme into css - fn as_css(&self) -> String; - /// Serialize the theme as RON and write the CSS to the appropriate directories - /// Should be written in the XDG data directory for cosmic-theme - fn write(&self) -> Result<()>; -} - -impl Gtk4Output for Theme -where - C: Clone - + fmt::Debug - + Default - + Into - + Into - + From - + Serialize - + DeserializeOwned, -{ - fn as_css(&self) -> String { + pub fn as_gtk4(&self) -> String { let Self { background, primary, secondary, accent, destructive, + warning, + success, + palette, .. } = self; - let mut css = String::new(); - css.push_str(&background.as_css()); - css.push_str(&primary.as_css()); - css.push_str(&secondary.as_css()); - css.push_str(&accent.as_css()); - css.push_str(&destructive.as_css()); + let window_bg = to_rgba(background.base); + let window_fg = to_rgba(background.on); + let view_bg = to_rgba(primary.base); + let view_fg = to_rgba(primary.on); + + let headerbar_bg = to_rgba(background.base); + let headerbar_fg = to_rgba(background.on); + let headerbar_border_color = to_rgba(background.divider); + + let sidebar_bg = to_rgba(primary.base); + let sidebar_fg = to_rgba(primary.on); + let sidebar_shade = to_rgba(if self.is_dark { + Rgba::new(0.0, 0.0, 0.0, 0.08) + } else { + Rgba::new(0.0, 0.0, 0.0, 0.32) + }); + let backdrop_overlay = Srgba::new(1.0, 1.0, 1.0, if self.is_dark { 0.08 } else { 0.32 }); + let sidebar_backdrop = to_rgba(over(backdrop_overlay, primary.base)); + + let secondary_sidebar_bg = to_rgba(secondary.base); + let secondary_sidebar_fg = to_rgba(secondary.on); + let secondary_sidebar_shade = to_rgba(if self.is_dark { + Rgba::new(0.0, 0.0, 0.0, 0.08) + } else { + Rgba::new(0.0, 0.0, 0.0, 0.32) + }); + let secondary_sidebar_backdrop = to_rgba(over(backdrop_overlay, secondary.base)); + + let headerbar_backdrop = to_rgba(background.base); + + let card_bg = to_rgba(background.component.base); + let card_fg = to_rgba(background.component.on); + + let thumbnail_bg = to_rgba(background.component.base); + let thumbnail_fg = to_rgba(background.component.on); + + let dialog_bg = to_rgba(primary.base); + let dialog_fg = to_rgba(primary.on); + + let popover_bg = to_rgba(background.component.base); + let popover_fg = to_rgba(background.component.on); + + let shade = to_rgba(if self.is_dark { + Rgba::new(0.0, 0.0, 0.0, 0.32) + } else { + Rgba::new(0.0, 0.0, 0.0, 0.08) + }); + + let inverted_bg_divider = background.base.with_alpha(0.5); + let scrollbar_outline = to_rgba(inverted_bg_divider); + + let mut css = format! {r#"/* GENERATED BY COSMIC */ +@define-color window_bg_color {window_bg}; +@define-color window_fg_color {window_fg}; + +@define-color view_bg_color {view_bg}; +@define-color view_fg_color {view_fg}; + +@define-color headerbar_bg_color {headerbar_bg}; +@define-color headerbar_fg_color {headerbar_fg}; +@define-color headerbar_border_color_color {headerbar_border_color}; +@define-color headerbar_backdrop_color {headerbar_backdrop}; + +@define-color sidebar_bg_color {sidebar_bg}; +@define-color sidebar_fg_color {sidebar_fg}; +@define-color sidebar_shade_color {sidebar_shade}; +@define-color sidebar_backdrop_color {sidebar_backdrop}; + +@define-color secondary_sidebar_bg_color {secondary_sidebar_bg}; +@define-color secondary_sidebar_fg_color {secondary_sidebar_fg}; +@define-color secondary_sidebar_shade_color {secondary_sidebar_shade}; +@define-color secondary_sidebar_backdrop_color {secondary_sidebar_backdrop}; + +@define-color card_bg_color {card_bg}; +@define-color card_fg_color {card_fg}; + +@define-color thumbnail_bg_color {thumbnail_bg}; +@define-color thumbnail_fg_color {thumbnail_fg}; + +@define-color dialog_bg_color {dialog_bg}; +@define-color dialog_fg_color {dialog_fg}; + +@define-color popover_bg_color {popover_bg}; +@define-color popover_fg_color {popover_fg}; + +@define-color shade_color {shade}; +@define-color scrollbar_outline_color {scrollbar_outline}; +"#}; + + css.push_str(&component_gtk4_css("accent", accent)); + css.push_str(&component_gtk4_css("destructive", destructive)); + css.push_str(&component_gtk4_css("warning", warning)); + css.push_str(&component_gtk4_css("success", success)); + css.push_str(&component_gtk4_css("accent", accent)); + css.push_str(&component_gtk4_css("error", destructive)); + + css.push_str(&color_css("blue", palette.accent_blue)); + css.push_str(&color_css("green", palette.accent_green)); + css.push_str(&color_css("yellow", palette.accent_yellow)); + css.push_str(&color_css("red", palette.accent_red)); + css.push_str(&color_css("orange", palette.ext_orange)); + css.push_str(&color_css("purple", palette.ext_purple)); + let neutral_steps = steps(palette.neutral_5, NonZeroUsize::new(10).unwrap()); + for (i, c) in neutral_steps[..5].iter().enumerate() { + css.push_str(&format!("@define-color light_{i} {};\n", to_rgba(*c))); + } + for (i, c) in neutral_steps[5..].iter().enumerate() { + css.push_str(&format!("@define-color dark_{i} {};\n", to_rgba(*c))); + } css } - fn write(&self) -> Result<()> { - // TODO sass -> css - let ron_str = ron::ser::to_string_pretty(self, Default::default())?; - let css_str = self.as_css(); + /// write the CSS to the appropriate directory + /// Should be written in the XDG config directory for gtk-4.0 + /// + /// # Errors + /// + /// Returns an `OutputError` if there is an error writing the CSS file. + #[cold] + pub fn write_gtk4(&self) -> Result<(), OutputError> { + let css_str = self.as_gtk4(); + let Some(mut config_dir) = dirs::config_dir() else { + return Err(OutputError::MissingConfigDir); + }; - let ron_path: PathBuf = [NAME, THEME_DIR].iter().collect(); - let css_path: PathBuf = [NAME, CSS_DIR].iter().collect(); - - let ron_dirs = xdg::BaseDirectories::with_prefix(ron_path)?; - let css_dirs = xdg::BaseDirectories::with_prefix(css_path)?; - - let ron_name = format!("{}.ron", &self.name); - let css_name = format!("{}.css", &self.name); - - if let Ok(p) = ron_dirs.place_data_file(ron_name) { - let mut f = File::create(p)?; - f.write_all(ron_str.as_bytes())?; + let name = if self.is_dark { + "dark.css" } else { - bail!("Failed to write RON theme.") + "light.css" + }; + + config_dir.extend(["gtk-4.0", "cosmic"]); + if !config_dir.exists() { + std::fs::create_dir_all(&config_dir).map_err(OutputError::Io)?; } - if let Ok(p) = css_dirs.place_data_file(css_name) { - let mut f = File::create(p)?; - f.write_all(css_str.as_bytes())?; - } else { - bail!("Failed to write RON theme.") + let file_path = config_dir.join(name); + let tmp_file_path = config_dir.join(name.to_owned() + "~"); + + // Write to tmp_file_path first, then move it to file_path + let mut tmp_file = File::create(&tmp_file_path).map_err(OutputError::Io)?; + let res = tmp_file + .write_all(css_str.as_bytes()) + .and_then(|_| tmp_file.flush()) + .and_then(|_| std::fs::rename(&tmp_file_path, file_path)); + if let Err(e) = res { + _ = std::fs::remove_file(&tmp_file_path); + return Err(OutputError::Io(e)); } Ok(()) } -} -/// Trait for converting theme data into gtk4 CSS -pub trait AsGtk4Css -where - C: Copy + Into + From, -{ - /// function for converting theme data into gtk4 CSS - fn as_css(&self) -> String; -} - -impl AsGtk4Css for Container -where - C: Copy + Clone + fmt::Debug + Default + Into + From + fmt::Display, -{ - fn as_css(&self) -> String { - let Self { - prefix, - container, - container_component, - container_divider, - container_fg, - .. - } = self; - - let prefix_lower = match prefix { - ContainerType::Background => "background", - ContainerType::Primary => "primary", - ContainerType::Secondary => "secondary", + /// Apply gtk color variable settings + /// + /// # Errors + /// + /// Returns an `OutputError` if there is an error applying the CSS file. + #[cold] + pub fn apply_gtk(is_dark: bool) -> Result<(), OutputError> { + let Some(config_dir) = dirs::config_dir() else { + return Err(OutputError::MissingConfigDir); }; - let component = widget_gtk4_css(prefix_lower, container_component); - format!( - r#" -@define-color {prefix_lower}_container #{{{container}}}; -@define-color {prefix_lower}_container_divider #{{{container_divider}}}; -@define-color {prefix_lower}_container_fg #{{{container_fg}}}; -{component} -"# - ) + let mut gtk4 = config_dir.join("gtk-4.0"); + let mut gtk3 = config_dir.join("gtk-3.0"); + + fs::create_dir_all(>k4).map_err(OutputError::Io)?; + fs::create_dir_all(>k3).map_err(OutputError::Io)?; + + let cosmic_css_dir = gtk4.join("cosmic"); + let cosmic_css = cosmic_css_dir.join(if is_dark { "dark.css" } else { "light.css" }); + + gtk4.push("gtk.css"); + gtk3.push("gtk.css"); + + #[cfg(target_family = "unix")] + for gtk_dest in [>k4, >k3] { + use std::os::unix::fs::symlink; + Self::backup_non_cosmic_css(gtk_dest, &cosmic_css_dir).map_err(OutputError::Io)?; + + if gtk_dest.exists() { + fs::remove_file(gtk_dest).map_err(OutputError::Io)?; + } + + symlink(&cosmic_css, gtk_dest).map_err(OutputError::Io)?; + } + Ok(()) + } + + /// Reset the applied gtk css + /// + /// # Errors + /// + /// Returns an `OutputError` if there is an error resetting the CSS file. + #[cold] + pub fn reset_gtk() -> Result<(), OutputError> { + let Some(config_dir) = dirs::config_dir() else { + return Err(OutputError::MissingConfigDir); + }; + + let gtk4 = config_dir.join("gtk-4.0"); + let gtk3 = config_dir.join("gtk-3.0"); + let gtk4_dest = gtk4.join("gtk.css"); + let cosmic_css = gtk4.join("cosmic"); + let gtk3_dest = gtk3.join("gtk.css"); + + let res = Self::reset_cosmic_css(>k3_dest, &cosmic_css).map_err(OutputError::Io); + Self::reset_cosmic_css(>k4_dest, &cosmic_css).map_err(OutputError::Io)?; + res + } + + #[cold] + fn backup_non_cosmic_css(path: &Path, cosmic_css: &Path) -> io::Result<()> { + if !Self::is_cosmic_css(path, cosmic_css)?.unwrap_or(true) { + let backup_path = path.with_extension("css.bak"); + fs::rename(path, &backup_path)?; + } + Ok(()) + } + + #[cold] + fn reset_cosmic_css(path: &Path, cosmic_css: &Path) -> io::Result<()> { + if Self::is_cosmic_css(path, cosmic_css)?.unwrap_or_default() { + fs::remove_file(path)?; + } + Ok(()) + } + + fn is_cosmic_css(path: &Path, cosmic_css: &Path) -> io::Result> { + if !path.exists() { + return Ok(None); + } + + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() { + if let Ok(actual_cosmic_css) = fs::read_link(path) { + let canonical_target = fs::canonicalize(&actual_cosmic_css)?; + let canonical_base = fs::canonicalize(cosmic_css)?; + return Ok(Some( + canonical_target == canonical_base + || canonical_target.starts_with(&canonical_base), + )); + } + } + } + Ok(Some(false)) } } -impl AsGtk4Css for Accent -where - C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, -{ - fn as_css(&self) -> String { - let Accent { - accent, - accent_fg, - accent_nav_handle_fg, - suggested, - } = self; - let suggested = widget_gtk4_css("suggested", suggested); - - format!( - r#" -@define-color accent #{{{accent}}}; -@define-color accent_fg #{{{accent_fg}}}; -@define-color accent_nav_handle_fg #{{{accent_nav_handle_fg}}}; -{suggested} -"# - ) - } -} - -impl AsGtk4Css for Destructive -where - C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, -{ - fn as_css(&self) -> String { - let Destructive { destructive } = &self; - widget_gtk4_css("destructive", destructive) - } -} - -fn widget_gtk4_css( - prefix: &str, - Widget { - base, - hover, - pressed, - focused, - divider, - text, - text_opacity_80, - disabled, - disabled_fg, - }: &Widget, -) -> String { +fn component_gtk4_css(prefix: &str, c: &Component) -> String { format!( r#" -@define-color {prefix}_widget_base #{{{base}}}; -@define-color {prefix}_widget_hover #{{{hover}}}; -@define-color {prefix}_widget_pressed #{{{pressed}}}; -@define-color {prefix}_widget_focused #{{{focused}}}; -@define-color {prefix}_widget_divider #{{{divider}}}; -@define-color {prefix}_widget_fg #{{{text}}}; -@define-color {prefix}_widget_fg_opacity_80 #{{{text_opacity_80}}}; -@define-color {prefix}_widget_disabled #{{{disabled}}}; -@define-color {prefix}_widget_disabled_fg #{{{disabled_fg}}}; -"# +@define-color {prefix}_color {}; +@define-color {prefix}_bg_color {}; +@define-color {prefix}_fg_color {}; +"#, + to_rgba(c.base), + to_rgba(c.base), + to_rgba(c.on), ) } + +fn color_css(prefix: &str, c_3: Srgba) -> String { + let oklch: palette::Oklch = c_3.into_color(); + let c_2: Srgba = oklch.lighten(0.1).into_color(); + let c_1: Srgba = oklch.lighten(0.2).into_color(); + let c_4: Srgba = oklch.darken(0.1).into_color(); + let c_5: Srgba = oklch.darken(0.2).into_color(); + let c_1 = to_rgba(c_1); + let c_2 = to_rgba(c_2); + let c_3 = to_rgba(c_3); + let c_4 = to_rgba(c_4); + let c_5 = to_rgba(c_5); + + format! {r#" +@define-color {prefix}_1 {c_1}; +@define-color {prefix}_2 {c_2}; +@define-color {prefix}_3 {c_3}; +@define-color {prefix}_4 {c_4}; +@define-color {prefix}_5 {c_5}; +"#} +} diff --git a/cosmic-theme/src/output/mod.rs b/cosmic-theme/src/output/mod.rs index 31307629..19f7bc5b 100644 --- a/cosmic-theme/src/output/mod.rs +++ b/cosmic-theme/src/output/mod.rs @@ -1,8 +1,89 @@ -#[cfg(feature = "gtk4-theme")] +use configparser::ini::WriteOptions; +use palette::{Srgba, rgb::Rgba}; +use thiserror::Error; + +use crate::Theme; + /// Module for outputting the Cosmic gtk4 theme type as CSS pub mod gtk4_output; -#[cfg(feature = "gtk4-theme")] -pub use gtk4_output::*; -#[cfg(feature = "ron-serialization")] -pub use ron::*; +/// Module for configuring qt5ct and qt6ct to use our qt theme +pub mod qt56ct_output; +/// Module for outputting the Cosmic qt theme type as kdeglobals +pub mod qt_output; + +pub mod vs_code; + +#[derive(Error, Debug)] +pub enum OutputError { + #[error("IO Error: {0}")] + Io(std::io::Error), + #[error("Missing config directory")] + MissingConfigDir, + #[error("Missing data directory")] + MissingDataDir, + #[error("Serde Error: {0}")] + Serde(#[from] serde_json::Error), + #[error("Ini Error: {0}")] + Ini(String), +} + +impl Theme { + #[inline] + /// Apply COSMIC theme exports for GTK and Qt applications. + pub fn apply_exports(&self) -> Result<(), OutputError> { + let gtk_res = Theme::apply_gtk(self.is_dark); + let qt_res = Theme::apply_qt(self.is_dark); + let qt56ct_res = Theme::apply_qt56ct(self.is_dark); + gtk_res?; + qt_res?; + qt56ct_res?; + Ok(()) + } + + #[inline] + /// Write COSMIC theme exports for GTK and Qt applications. + pub fn write_exports(&self) -> Result<(), OutputError> { + let gtk_res = self.write_gtk4(); + let qt_res = self.write_qt(); + let qt56ct_res = self.write_qt56ct(); + gtk_res?; + qt_res?; + qt56ct_res?; + Ok(()) + } + + #[inline] + /// Un-export GTK and Qt theme configurations applied by us. + pub fn reset_exports() -> Result<(), OutputError> { + let gtk_res = Theme::reset_gtk(); + let qt_res = Theme::reset_qt(); + let qt56ct_res = Theme::reset_qt56ct(); + gtk_res?; + qt_res?; + qt56ct_res?; + Ok(()) + } +} + +pub fn to_hex(c: Srgba) -> String { + let c_u8: Rgba = c.into_format(); + format!( + "{:02x}{:02x}{:02x}{:02x}", + c_u8.red, c_u8.green, c_u8.blue, c_u8.alpha + ) +} + +pub fn to_rgba(c: Srgba) -> String { + let c_u8: Rgba = c.into_format(); + format!( + "rgba({}, {}, {}, {:1.2})", + c_u8.red, c_u8.green, c_u8.blue, c.alpha + ) +} + +pub fn qt_settings_ini_style() -> WriteOptions { + let mut write_options = WriteOptions::default(); + write_options.blank_lines_between_sections = 1; + write_options +} diff --git a/cosmic-theme/src/output/qt56ct_output.rs b/cosmic-theme/src/output/qt56ct_output.rs new file mode 100644 index 00000000..43a45470 --- /dev/null +++ b/cosmic-theme/src/output/qt56ct_output.rs @@ -0,0 +1,415 @@ +use crate::Theme; +use configparser::ini::Ini; +use palette::{Mix, Srgba, WithAlpha, blend::Compose, rgb::Rgba}; +use std::{ + fs::{self, File}, + io::Write, + path::PathBuf, + vec, +}; + +use super::{OutputError, qt_settings_ini_style}; + +impl Theme { + /// The "version" of this theme. + /// + /// To avoid repeatedly overwriting the user's config, we use a version system. + /// + /// Increment this value when changes to qt{5,6}ct.conf are needed. + /// If the config's version is outdated, we update several sections. + /// Otherwise, only the light/dark mode is updated. + const COSMIC_QT_VERSION: u64 = 2; + + /// Produces a QPalette ini file for qt5ct and qt6ct. + /// + /// Example file: https://github.com/trialuser02/qt6ct/blob/master/colors/airy.conf + #[must_use] + #[cold] + pub fn as_qpalette(&self) -> String { + let lightest = if self.is_dark { + self.background.on + } else { + self.background.base + }; + let darkest = if self.is_dark { + self.background.base + } else { + self.background.on + }; + let active = QPaletteGroup { + window_text: self.background.on, + button: self.button.base, + light: self.button.base.mix(lightest, 0.1), + midlight: self.button.base.mix(lightest, 0.05), + dark: self.button.base.mix(darkest, 0.1), + mid: self.button.base.mix(darkest, 0.05), + text: self.background.component.on, + bright_text: lightest, + button_text: self.button.on, + base: self.background.component.base, + window: self.background.base, + shadow: darkest, + // selection colors are swapped to fix menu bar contrast + highlight: self.background.component.selected_text, + highlighted_text: self.background.component.selected, + link: self.link_button.on, + link_visited: self.link_button.on.mix(self.secondary.component.base, 0.2), + alternate_base: self.background.base.mix(self.accent.base, 0.05), + no_role: self.background.component.disabled, + tool_tip_base: self.background.component.base, + tool_tip_text: self.background.component.on, + placeholder_text: self.background.component.on.with_alpha(0.5), + }; + let inactive = QPaletteGroup { + window_text: active.window_text.with_alpha(0.8), + text: active.text.with_alpha(0.8), + highlighted_text: active.highlighted_text.with_alpha(0.8), + tool_tip_text: active.tool_tip_text.with_alpha(0.8), + ..active + }; + let disabled = QPaletteGroup { + button: self.button.disabled, + text: self.background.component.on_disabled, + button_text: self.button.on_disabled, + base: self.background.component.disabled, + highlighted_text: active.highlighted_text.with_alpha(0.5), + link: self.link_button.on_disabled, + link_visited: self + .link_button + .on_disabled + .mix(self.secondary.component.disabled, 0.2), + alternate_base: self.background.base.mix(self.accent.disabled, 0.05), + tool_tip_base: self.background.component.disabled, + tool_tip_text: self.background.component.on_disabled, + placeholder_text: self.background.component.on_disabled.with_alpha(0.5), + ..inactive + }; + + format!( + r#"# GENERATED BY COSMIC + +[ColorScheme] +active_colors={} +disabled_colors={} +inactive_colors={} +"#, + active.as_list(), + disabled.as_list(), + inactive.as_list(), + ) + } + + /// Writes the QPalette ini files to: + /// - `~/.config/qt6ct/colors/` + /// - `~/.config/qt5ct/colors/` + #[cold] + pub fn write_qt56ct(&self) -> Result<(), OutputError> { + let qpalette = self.as_qpalette(); + let qt5ct_res = self.write_ct("qt5ct", &qpalette); + let qt6ct_res = self.write_ct("qt6ct", &qpalette); + qt5ct_res?; + qt6ct_res?; + Ok(()) + } + #[must_use] + #[cold] + fn write_ct(&self, ct: &str, qpalette: &str) -> Result<(), OutputError> { + let file_path = Self::get_qpalette_path(ct, self.is_dark)?; + let tmp_file_path = file_path.with_extension("conf.new"); + + let mut tmp_file = File::create(&tmp_file_path).map_err(OutputError::Io)?; + let res = tmp_file + .write_all(qpalette.as_bytes()) + .and_then(|_| tmp_file.flush()) + .and_then(|_| std::fs::rename(&tmp_file_path, file_path)); + if let Err(e) = res { + _ = std::fs::remove_file(&tmp_file_path); + return Err(OutputError::Io(e)); + } + + Ok(()) + } + + /// Edits qt{5,6}ct.conf to use COSMIC styles if needed. + #[cold] + pub fn apply_qt56ct(is_dark: bool) -> Result<(), OutputError> { + let qt5ct_res = Self::apply_ct("qt5ct", is_dark); + let qt6ct_res = Self::apply_ct("qt6ct", is_dark); + qt5ct_res?; + qt6ct_res?; + Ok(()) + } + #[must_use] + #[cold] + fn apply_ct(ct: &str, is_dark: bool) -> Result<(), OutputError> { + let path = Self::get_conf_path(ct)?; + let file_content = fs::read_to_string(&path).map_err(OutputError::Io)?; + let mut ini = Ini::new_cs(); + ini.read(file_content).map_err(OutputError::Ini)?; + + let old_version = ini + .getuint("Appearance", "cosmic_qt_version") + .map_err(OutputError::Ini)? + .unwrap_or_default(); + + let color_scheme_path = Self::get_qpalette_path(ct, is_dark)?; + let icon_theme = if is_dark { "breeze-dark" } else { "breeze" }; + + ini.set( + "Appearance", + "cosmic_qt_version", + Some(Theme::COSMIC_QT_VERSION.to_string()), + ); + + if old_version < Theme::COSMIC_QT_VERSION { + // Config is outdated, update it unconditionally! + + ini.setstr( + "Appearance", + "color_scheme_path", + color_scheme_path.to_str(), + ); + // Enable the above color scheme, instead of using the default color scheme of e.g. Breeze + ini.setstr("Appearance", "custom_palette", Some("true")); + // COSMIC icons are stuck in light mode, so use breeze icons instead + ini.setstr("Appearance", "icon_theme", Some(icon_theme)); + // Use COSMIC dialogs instead of KDE's + ini.setstr("Appearance", "standard_dialogs", Some("xdgdesktopportal")); + + // TODO: Add fonts section to match COSMIC + } else { + // Config is not outdated, check before updating light/dark mode only! + + let old_color_scheme_path = ini + .get("Appearance", "color_scheme_path") + .unwrap_or_else(|| "CosmicPlease".to_owned()); + if old_color_scheme_path.contains("Cosmic") { + ini.setstr( + "Appearance", + "color_scheme_path", + color_scheme_path.to_str(), + ); + } + + let old_icon_theme = ini + .get("Appearance", "icon_theme") + .unwrap_or_else(|| "breeze".to_owned()); + if old_icon_theme.contains("breeze") { + ini.setstr("Appearance", "icon_theme", Some(icon_theme)); + } + } + + ini.pretty_write(path, &qt_settings_ini_style()) + .map_err(OutputError::Io)?; + Ok(()) + } + + /// Reset the applied qt56ct config by removing COSMIC-specific entries from the config file. + #[cold] + pub fn reset_qt56ct() -> Result<(), OutputError> { + let qt5ct_res = Self::reset_ct("qt5ct"); + let qt6ct_res = Self::reset_ct("qt6ct"); + qt5ct_res?; + qt6ct_res?; + Ok(()) + } + #[must_use] + #[cold] + fn reset_ct(ct: &str) -> Result<(), OutputError> { + let path = Self::get_conf_path(ct)?; + let file_content = fs::read_to_string(&path).map_err(OutputError::Io)?; + let mut ini = Ini::new_cs(); + ini.read(file_content).map_err(OutputError::Ini)?; + + let old_version = ini + .getuint("Appearance", "cosmic_qt_version") + .map_err(OutputError::Ini)? + .unwrap_or_default(); + if old_version == 0 { + return Ok(()); + } + + ini.remove_key("Appearance", "cosmic_qt_version"); + ini.remove_key("Appearance", "color_scheme_path"); + ini.remove_key("Appearance", "icon_theme"); + + ini.pretty_write(path, &qt_settings_ini_style()) + .map_err(OutputError::Io)?; + Ok(()) + } + + /// Returns the file paths of the form `~/.config/ct/ct.conf`: + /// e.g. `~/.config/qt6ct/qt6ct.conf`. + /// + /// The file and its parent directory are created if they don't exist. + #[cold] + fn get_conf_path(ct: &str) -> Result { + assert!(ct == "qt5ct" || ct == "qt6ct"); + + let Some(mut config_dir) = dirs::config_dir() else { + return Err(OutputError::MissingConfigDir); + }; + config_dir.push(&ct); + if !config_dir.exists() { + fs::create_dir_all(&config_dir).map_err(OutputError::Io)?; + } + + let file_path = config_dir.join(ct.to_owned() + ".conf"); + if !file_path.exists() { + File::create_new(&file_path).map_err(OutputError::Io)?; + } + + Ok(file_path) + } + + /// Gets a path like `~/.config/qt6ct/colors/CosmicDark.conf` + /// + /// Its parent directory is created if it doesn't exist. + #[cold] + fn get_qpalette_path(ct: &str, is_dark: bool) -> Result { + assert!(ct == "qt5ct" || ct == "qt6ct"); + + let Some(mut config_dir) = dirs::config_dir() else { + return Err(OutputError::MissingConfigDir); + }; + config_dir.push(&ct); + config_dir.push("colors"); + if !config_dir.exists() { + fs::create_dir_all(&config_dir).map_err(OutputError::Io)?; + } + + let file_name = if is_dark { + "CosmicDark.conf" + } else { + "CosmicLight.conf" + }; + + Ok(config_dir.join(file_name)) + } +} + +/// Defines the different symbolic color roles used in current GUIs. +/// +/// qt5ct and qt6ct consume this as a list of colors, ordered by ColorRole: +/// - https://doc.qt.io/qt-6/qpalette.html#ColorRole-enum +/// - https://doc.qt.io/archives/qt-5.15/qpalette.html#ColorRole-enum +struct QPaletteGroup { + /// A general foreground color. + window_text: Srgba, + /// The general button background color. + button: Srgba, + /// Lighter than [button] color, used mostly for 3D bevel and shadow effects. + light: Srgba, + /// Between [button] and [light], used mostly for 3D bevel and shadow effects. + midlight: Srgba, + /// Darker than [button], used mostly for 3D bevel and shadow effects. + dark: Srgba, + /// Between [button] and [dark], used mostly for 3D bevel and shadow effects. + mid: Srgba, + /// The foreground color used with [base]. + text: Srgba, + /// A text color that is very different from [window_text], and contrasts well with e.g. [dark]. + /// Typically used for text that needs to be drawn where [text] or [window_text] would give poor contrast, such as on pressed push buttons. + bright_text: Srgba, + /// A foreground color used with the [button] color. + button_text: Srgba, + /// Used mostly as the background color for text entry widgets, but can also be used for other painting - + /// such as the background of combobox drop down lists and toolbar handles. + base: Srgba, + /// A general background color. + window: Srgba, + /// A very dark color, used mostly for 3D bevel and shadow effects. + /// Opaque black by default. + shadow: Srgba, + /// A color to indicate a selected item or the current item. + highlight: Srgba, + /// A text color that contrasts with [highlight]. + highlighted_text: Srgba, + /// A text color used for unvisited hyperlinks. + link: Srgba, + /// A text color used for already visited hyperlinks. + link_visited: Srgba, + /// Used as the alternate background color in views with alternating row colors. + alternate_base: Srgba, + /// No role; this special role is often used to indicate that a role has not been assigned. + no_role: Srgba, + /// Used as the background color for QToolTip and QWhatsThis. + /// Tool tips use the inactive color group of QPalette, because tool tips are not active windows. + tool_tip_base: Srgba, + /// Used as the foreground color for QToolTip and QWhatsThis. + /// Tool tips use the inactive color group of QPalette, because tool tips are not active windows. + tool_tip_text: Srgba, + /// Used as the placeholder color for various text input widgets. + placeholder_text: Srgba, + // /// [accent] only exists since Qt 6.6. Including it here breaks qt5ct. + // /// When omitted, it defaults to [highlight]. + // accent: Srgba, +} + +impl QPaletteGroup { + /// Returns a comma-separated list of the colors as hex codes. + /// E.g. `#ff000000, #ffdcdcdc, ...` + /// + /// Any transparent colors are flattened with [base] to avoid issues with + /// the Fusion style. + fn as_list(&self) -> String { + let colors = vec![ + to_argb_hex(self.window_text.over(self.base)), + to_argb_hex(self.button.over(self.base)), + to_argb_hex(self.light.over(self.base)), + to_argb_hex(self.midlight.over(self.base)), + to_argb_hex(self.dark.over(self.base)), + to_argb_hex(self.mid.over(self.base)), + to_argb_hex(self.text.over(self.base)), + to_argb_hex(self.bright_text.over(self.base)), + to_argb_hex(self.button_text.over(self.base)), + to_argb_hex(self.base.over(self.base)), + to_argb_hex(self.window.over(self.base)), + to_argb_hex(self.shadow.over(self.base)), + to_argb_hex(self.highlight.over(self.base)), + to_argb_hex(self.highlighted_text.over(self.base)), + to_argb_hex(self.link.over(self.base)), + to_argb_hex(self.link_visited.over(self.base)), + to_argb_hex(self.alternate_base.over(self.base)), + to_argb_hex(self.no_role.over(self.base)), + to_argb_hex(self.tool_tip_base.over(self.base)), + to_argb_hex(self.tool_tip_text.over(self.base)), + to_argb_hex(self.placeholder_text.over(self.base)), + ]; + colors.join(", ") + } +} + +/// Converts a color to a hex string in the format `#AARRGGBB`. +/// Do not use [to_hex] since that uses the format `RRGGBBAA`. +fn to_argb_hex(c: Srgba) -> String { + let c_u8: Rgba = c.into_format(); + format!( + "#{:02x}{:02x}{:02x}{:02x}", + c_u8.alpha, c_u8.red, c_u8.green, c_u8.blue + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_color_to_argb_hex() { + let color = Srgba::new(0x33, 0x55, 0x77, 0xff); + let argb = to_argb_hex(color.into()); + assert_eq!(argb, "#ff335577"); + } + + #[test] + fn test_light_default_qpalette() { + let light_default_qpalette = Theme::light_default().as_qpalette(); + insta::assert_snapshot!(light_default_qpalette); + } + + #[test] + fn test_dark_default_qpalette() { + let dark_default_qpalette = Theme::dark_default().as_qpalette(); + insta::assert_snapshot!(dark_default_qpalette); + } +} diff --git a/cosmic-theme/src/output/qt_output.rs b/cosmic-theme/src/output/qt_output.rs new file mode 100644 index 00000000..d42d553b --- /dev/null +++ b/cosmic-theme/src/output/qt_output.rs @@ -0,0 +1,568 @@ +use crate::Theme; +use configparser::ini::Ini; +use cosmic_config::CosmicConfigEntry; +use palette::{Mix, Srgba, blend::Compose}; +use std::{ + fs::{self, File}, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +use super::{OutputError, qt_settings_ini_style}; + +impl Theme { + /// Produces a color scheme ini file for Qt. + /// + /// Some high-level documentation for this file can be found at: + /// - https://api.kde.org/kcolorscheme.html + /// - https://web.archive.org/web/20250402234329/https://docs.kde.org/stable5/en/plasma-workspace/kcontrol/colors/ + #[must_use] + #[cold] + pub fn as_kcolorscheme(&self) -> String { + // Usually, disabled elements will have strongly reduced contrast and are often notably darker or lighter + let disabled_color_effects = IniColorEffects { + color: self.button.disabled, + color_amount: 0.0, + color_effect: ColorEffect::Desaturate, + contrast_amount: 0.65, + contrast_effect: ColorEffect::Fade, + intensity_amount: 0.1, + intensity_effect: IntensityEffect::Lighten, + }; + // Usually, inactive elements will have reduced contrast (text fades slightly into the background) and may have slightly reduced intensity + let inactive_color_effects = IniColorEffects { + color: self.palette.gray_1, + color_amount: 0.025, + color_effect: ColorEffect::Tint, + contrast_amount: 0.1, + contrast_effect: ColorEffect::Tint, + intensity_amount: 0.0, + intensity_effect: IntensityEffect::Shade, + }; + + let bg = self.background.base; + // the background container + let window_colors = IniColors { + background_alternate: bg.mix(self.accent.base, 0.05), + background_normal: bg, + decoration_focus: self.accent_text_color(), + decoration_hover: self.accent_text_color(), + foreground_active: self.accent_text_color(), + foreground_inactive: self.background.on.mix(bg, 0.1), + foreground_link: self.link_button.on, + foreground_negative: self.destructive_text_color(), + foreground_neutral: self.warning_text_color(), + foreground_normal: self.background.on, + foreground_positive: self.success_text_color(), + foreground_visited: self.accent_text_color(), + }; + // components inside the background container + let view_colors = IniColors { + background_alternate: self.background.component.base.mix(self.accent.base, 0.05), + background_normal: self.background.component.base, + ..window_colors + }; + + // selected text and items + let selection_colors = { + // selection colors are swapped to fix menu bar contrast + let selected = self.background.component.selected_text; + let selected_text = self.background.component.selected; + IniColors { + background_alternate: selected.mix(bg, 0.5), + background_normal: selected, + decoration_focus: selected, + decoration_hover: selected, + foreground_active: selected_text, + foreground_inactive: selected_text.mix(selected, 0.5), + foreground_link: self.link_button.base, + foreground_negative: self.destructive_color(), + foreground_neutral: self.warning_color(), + foreground_normal: selected_text, + foreground_positive: self.success_color(), + foreground_visited: self.accent_color(), + } + }; + + let button_colors = IniColors { + background_alternate: self.accent_button.base, + background_normal: self.button.base, + ..view_colors + }; + + // Complementary: Areas of applications with an alternative color scheme; usually with a dark background for light color schemes. + let complementary_colors = { + let dark = if self.is_dark { + self.clone() + } else if cfg!(test) { + // For reproducible results in tests, use the default dark theme + Theme::dark_default() + } else { + Theme::dark_config() + .ok() + .as_ref() + .and_then(|conf| Theme::get_entry(conf).ok()) + .unwrap_or_else(|| self.clone()) + }; + IniColors { + background_alternate: dark.accent.base, + background_normal: dark.background.base, + decoration_focus: dark.accent_text_color(), + decoration_hover: dark.accent_text_color(), + foreground_active: dark.accent_text_color(), + foreground_inactive: dark.background.on.mix(dark.background.base, 0.1), + foreground_link: dark.link_button.on, + foreground_negative: dark.destructive_text_color(), + foreground_neutral: dark.warning_text_color(), + foreground_normal: dark.background.on, + foreground_positive: dark.success_text_color(), + foreground_visited: dark.accent_text_color(), + } + }; + + // headers in cosmic don't have a background + let header_colors = &window_colors; + let header_colors_inactive = &window_colors; + // tool tips, "What's This" tips, and similar elements + let tooltip_colors = &view_colors; + + let general_color_scheme = if self.is_dark { + "CosmicDark" + } else { + "CosmicLight" + }; + let general_name = if self.is_dark { + "COSMIC Dark" + } else { + "COSMIC Light" + }; + // COSMIC icons are stuck in light mode, so use breeze icons instead + let icons_theme = if self.is_dark { + "breeze-dark" + } else { + "breeze" + }; + + format!( + r#"# GENERATED BY COSMIC + +[ColorEffects:Disabled] +{} + +[ColorEffects:Inactive] +ChangeSelectionColor=false +Enable=false +{} + +[Colors:Button] +{} + +[Colors:Complementary] +{} + +[Colors:Header] +{} + +[Colors:Header][Inactive] +{} + +[Colors:Selection] +{} + +[Colors:Tooltip] +{} + +[Colors:View] +{} + +[Colors:Window] +{} + +[General] +ColorScheme={general_color_scheme} +Name={general_name} +shadeSortColumn=true + +[Icons] +Theme={icons_theme} + +[KDE] +contrast=4 +widgetStyle=qt6ct-style + +[WM] +{} +"#, + format_ini_color_effects(&disabled_color_effects, bg), + format_ini_color_effects(&inactive_color_effects, bg), + format_ini_colors(&button_colors, bg), + format_ini_colors(&complementary_colors, bg), + format_ini_colors(&header_colors, bg), + format_ini_colors(&header_colors_inactive, bg), + format_ini_colors(&selection_colors, bg), + format_ini_colors(&tooltip_colors, bg), + format_ini_colors(&view_colors, bg), + format_ini_colors(&window_colors, bg), + format_ini_wm_colors(&window_colors, self.is_dark), + ) + } + + /// Write the color scheme to the appropriate directory. + /// Should be written in `~/.local/share/color-schemes/`. + /// + /// See the docs: https://develop.kde.org/docs/plasma/#color-scheme + /// + /// # Errors + /// + /// Returns an `OutputError` if there is an error writing the colors file. + #[cold] + pub fn write_qt(&self) -> Result<(), OutputError> { + let kcolorscheme = self.as_kcolorscheme(); + let file_path = Self::get_kcolorscheme_path(self.is_dark)?; + let tmp_file_path = file_path.with_extension("colors.new"); + + // Write to tmp_file_path first, then move it to file_path + let mut tmp_file = File::create(&tmp_file_path).map_err(OutputError::Io)?; + let res = tmp_file + .write_all(kcolorscheme.as_bytes()) + .and_then(|_| tmp_file.flush()) + .and_then(|_| std::fs::rename(&tmp_file_path, file_path)); + if let Err(e) = res { + _ = std::fs::remove_file(&tmp_file_path); + return Err(OutputError::Io(e)); + } + + Ok(()) + } + + /// Apply the color scheme by copying its values to `~/.config/kdeglobals`. + /// + /// See the docs: https://develop.kde.org/docs/plasma/#color-scheme + /// + /// # Errors + /// + /// Returns an `OutputError` if there is an error applying the color scheme. + #[cold] + pub fn apply_qt(is_dark: bool) -> Result<(), OutputError> { + let Some(config_dir) = dirs::config_dir() else { + return Err(OutputError::MissingConfigDir); + }; + let kdeglobals_file = config_dir.join("kdeglobals"); + let mut kdeglobals_ini = Self::read_ini(&kdeglobals_file)?; + + let src_file = Self::get_kcolorscheme_path(is_dark)?; + let src_ini = Self::read_ini(&src_file)?; + + Self::backup_non_cosmic_kdeglobals(&kdeglobals_ini, &kdeglobals_file) + .map_err(OutputError::Io)?; + + for (section, key_value) in src_ini.get_map_ref() { + for (key, value) in key_value { + kdeglobals_ini.set(section, key, value.clone()); + } + } + + kdeglobals_ini + .pretty_write(kdeglobals_file, &qt_settings_ini_style()) + .map_err(OutputError::Io)?; + Ok(()) + } + + /// Reset the applied qt colors by removing color scheme values from the + /// `~/.config/kdeglobals` file. + /// + /// This does not restore the backed up kdeglobals file. + /// + /// # Errors + /// + /// Returns an `OutputError` if there is an error resetting the CSS file. + #[cold] + pub fn reset_qt() -> Result<(), OutputError> { + let Some(config_dir) = dirs::config_dir() else { + return Err(OutputError::MissingConfigDir); + }; + let kdeglobals_file = config_dir.join("kdeglobals"); + let mut kdeglobals_ini = Self::read_ini(&kdeglobals_file)?; + + if !Self::is_cosmic_kdeglobals(&kdeglobals_ini) + .map_err(OutputError::Io)? + .unwrap_or_default() + { + // Not a cosmic kdeglobals file, do nothing + return Ok(()); + } + + let is_dark = false; // doesn't matter since we're only reading keys + let src_file = Self::get_kcolorscheme_path(is_dark)?; + let src_ini = Self::read_ini(&src_file)?; + + for (section, key_value) in src_ini.get_map_ref() { + for (key, _) in key_value { + kdeglobals_ini.remove_key(section, key); + } + } + + kdeglobals_ini + .write(kdeglobals_file) + .map_err(OutputError::Io)?; + Ok(()) + } + + /// Gets a path like `~/.local/share/color-schemes/CosmicDark.colors` + fn get_kcolorscheme_path(is_dark: bool) -> Result { + let Some(mut data_dir) = dirs::data_dir() else { + return Err(OutputError::MissingDataDir); + }; + + let file_name = if is_dark { + "CosmicDark.colors" + } else { + "CosmicLight.colors" + }; + + data_dir.push("color-schemes"); + if !data_dir.exists() { + std::fs::create_dir_all(&data_dir).map_err(OutputError::Io)?; + } + + Ok(data_dir.join(file_name)) + } + + #[cold] + fn read_ini(path: &PathBuf) -> Result { + let mut ini = Ini::new_cs(); + if !path.exists() { + return Ok(ini); + } + let file_content = fs::read_to_string(path).map_err(OutputError::Io)?; + ini.read(file_content).map_err(OutputError::Ini)?; + Ok(ini) + } + + #[cold] + fn backup_non_cosmic_kdeglobals(ini: &Ini, path: &Path) -> io::Result<()> { + if !Self::is_cosmic_kdeglobals(&ini)?.unwrap_or(true) { + let backup_path = path.with_extension("bak"); + fs::copy(path, &backup_path)?; + } + Ok(()) + } + + #[cold] + fn is_cosmic_kdeglobals(ini: &Ini) -> io::Result> { + let color_scheme = ini.get("General", "ColorScheme"); + if let Some(color_scheme) = color_scheme { + Ok(Some( + color_scheme == "CosmicDark" || color_scheme == "CosmicLight", + )) + } else { + Ok(None) + } + } +} + +/// Formats a color in the form `r,g,b` e.g. `255,255,255`. +/// If the color has transparency, it is mixed with bg first. +fn to_rgb(c: Srgba, bg: Srgba) -> String { + let c_u8: Srgba = c.over(bg).into_format(); + format!("{},{},{}", c_u8.red, c_u8.green, c_u8.blue) +} + +fn format_ini_color_effects(color_effects: &IniColorEffects, bg: Srgba) -> String { + format!( + r#"Color={} +ColorAmount={} +ColorEffect={} +ContrastAmount={} +ContrastEffect={} +IntensityAmount={} +IntensityEffect={}"#, + to_rgb(color_effects.color, bg), + color_effects.color_amount, + color_effects.color_effect.as_u8(), + color_effects.contrast_amount, + color_effects.contrast_effect.as_u8(), + color_effects.intensity_amount, + color_effects.intensity_effect.as_u8(), + ) +} + +fn format_ini_colors(colors: &IniColors, bg: Srgba) -> String { + format!( + r#"BackgroundAlternate={} +BackgroundNormal={} +DecorationFocus={} +DecorationHover={} +ForegroundActive={} +ForegroundInactive={} +ForegroundLink={} +ForegroundNegative={} +ForegroundNeutral={} +ForegroundNormal={} +ForegroundPositive={} +ForegroundVisited={}"#, + to_rgb(colors.background_alternate, bg), + to_rgb(colors.background_normal, bg), + to_rgb(colors.decoration_focus, bg), + to_rgb(colors.decoration_hover, bg), + to_rgb(colors.foreground_active, bg), + to_rgb(colors.foreground_inactive, bg), + to_rgb(colors.foreground_link, bg), + to_rgb(colors.foreground_negative, bg), + to_rgb(colors.foreground_neutral, bg), + to_rgb(colors.foreground_normal, bg), + to_rgb(colors.foreground_positive, bg), + to_rgb(colors.foreground_visited, bg), + ) +} + +/// Sets the colors for the titlebars of active and inactive windows. +fn format_ini_wm_colors(view_colors: &IniColors, is_dark: bool) -> String { + let bg = view_colors.background_normal; + let fg = view_colors.foreground_active; + let blend = if is_dark { fg } else { bg }; + + format!( + r#"activeBackground={} +activeBlend={} +activeForeground={} +inactiveBackground={} +inactiveBlend={} +inactiveForeground={}"#, + to_rgb(bg, bg), + to_rgb(blend, bg), + to_rgb(fg, bg), + to_rgb(bg, bg), + to_rgb(blend, bg), + to_rgb(fg, bg), + ) +} + +struct IniColorEffects { + color: Srgba, + color_amount: f32, + color_effect: ColorEffect, + contrast_amount: f32, + /// Applied to the text, using the background as the reference color. + contrast_effect: ColorEffect, + intensity_amount: f32, + intensity_effect: IntensityEffect, +} +/// Each color set is made up of a number of roles which are available in all other sets. +/// In addition, except for Inactive Text, there is a corresponding background role for each of the text roles. Currently (except for Normal and Alternate Background), these colors are not chosen here but are automatically determined based on Normal Background and the corresponding Text color. +struct IniColors { + /// used when there is a need to subtly change the background to aid in item association. This might be used e.g. as the background of a heading, but is mostly used for alternating rows in lists, especially multi-column lists, to aid in visually tracking rows. + background_alternate: Srgba, + /// Normal background + background_normal: Srgba, + /// Used for drawing lines or shading UI elements to indicate the item which has active input focus. + /// Typically the same as foreground_active. + decoration_focus: Srgba, + /// Used for drawing lines or shading UI elements for mouse-over effects, e.g. the "illumination" effects for buttons. + /// Typically the same as foreground_active. + decoration_hover: Srgba, + /// used to indicate an active element or attract attention, e.g. alerts, notifications; also for hovered hyperlinks + foreground_active: Srgba, + /// used for text which should be unobtrusive, e.g. comments, "subtitles", unimportant information, etc. + foreground_inactive: Srgba, + /// used for hyperlinks or to otherwise indicate "something which may be visited", or to show relationships + foreground_link: Srgba, + /// used for errors, failure notices, notifications that an action may be dangerous (e.g. unsafe web page or security context), etc. + foreground_negative: Srgba, + /// used to draw attention when another role is not appropriate; e.g. warnings, to indicate secure/encrypted content, etc. + foreground_neutral: Srgba, + /// Normal foreground + foreground_normal: Srgba, + /// used for success notices, to indicate trusted content, etc. + foreground_positive: Srgba, + /// used for "something (e.g. a hyperlink) that has been visited", or to indicate something that is "old". + foreground_visited: Srgba, +} + +/// Intensity allows the overall color to be lightened or darkened. +#[allow(dead_code)] +enum IntensityEffect { + /// Makes everything lighter or darker in a controlled manner. + /// + /// intensity_amount increases or decreases the overall intensity (i.e. perceived brightness) by an absolute amount. + Shade, + /// Changes the intensity to a percentage of the initial value. + Darken, + /// Conceptually the opposite of darken; lighten can be thought of as working with "distance from white", where darken works with "distance from black". + Lighten, +} + +impl IntensityEffect { + pub fn as_u8(&self) -> u8 { + match self { + Self::Shade => 0, + Self::Darken => 1, + Self::Lighten => 2, + } + } +} + +/// This also changes the overall color like [IntensityEffect], +/// but is not limited to intensity. +#[allow(dead_code)] +enum ColorEffect { + /// changes the relative chroma + /// + /// This is available for "ColorEffect" but not "ContrastEffect". + Desaturate, + /// smoothly blends the original color into a reference color + Fade, + /// similar to Fade, except that the color (hue and chroma) changes more quickly while the intensity changes more slowly as the amount is increased + Tint, +} + +impl ColorEffect { + pub fn as_u8(&self) -> u8 { + match self { + Self::Desaturate => 0, + Self::Fade => 1, + Self::Tint => 2, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_opaque_color_to_rgb() { + let color = Srgba::new(30.0 / 255.0, 50.0 / 255.0, 70.0 / 255.0, 1.0); + let bg = Srgba::new(1.0, 1.0, 1.0, 1.0); + let result = to_rgb(color, bg); + assert_eq!(result, "30,50,70"); + } + + #[test] + fn test_transparent_color_to_rgb() { + let color = Srgba::new(0.0, 0.0, 0.0, 0.0); + let bg = Srgba::new(1.0, 1.0, 1.0, 1.0); + let result = to_rgb(color, bg); + assert_eq!(result, "255,255,255"); + } + + #[test] + fn test_translucent_color_to_rgb() { + let color = Srgba::new(0.0, 0.0, 0.0, 0.9); + let bg = Srgba::new(1.0, 1.0, 1.0, 1.0); + let result = to_rgb(color, bg); + assert_eq!(result, "26,26,26"); + } + + #[test] + fn test_light_default_kcolorscheme() { + let light_default_kcolorscheme = Theme::light_default().as_kcolorscheme(); + insta::assert_snapshot!(light_default_kcolorscheme); + } + + #[test] + fn test_dark_default_kcolorscheme() { + let dark_default_kcolorscheme = Theme::dark_default().as_kcolorscheme(); + insta::assert_snapshot!(dark_default_kcolorscheme); + } +} diff --git a/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt56ct_output__tests__dark_default_qpalette.snap b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt56ct_output__tests__dark_default_qpalette.snap new file mode 100644 index 00000000..15746fd0 --- /dev/null +++ b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt56ct_output__tests__dark_default_qpalette.snap @@ -0,0 +1,10 @@ +--- +source: cosmic-theme/src/output/qt56ct_output.rs +expression: dark_default_qpalette +--- +# GENERATED BY COSMIC + +[ColorScheme] +active_colors=#ffe7e7e7, #ff4a4a4a, #ff555555, #ff505050, #ff4f4f4f, #ff4d4d4d, #ffc0c0c0, #ffe7e7e7, #ffc0c0c0, #ff2e2e2e, #ff1b1b1b, #ff1b1b1b, #ff63d0df, #ff434343, #ff63d0df, #ff5bb2be, #ff1f2425, #ff2e2e2e, #ff2e2e2e, #ffc0c0c0, #ff777777 +disabled_colors=#e6d3d3d3, #8f474747, #a9696969, #a4626262, #a95f5f5f, #a45d5d5d, #d2a1a1a1, #ffe7e7e7, #d2a1a1a1, #bf2e2e2e, #ff1b1b1b, #ff1b1b1b, #ff63d0df, #bf3c3c3c, #bf30555a, #bf324f53, #ff1f2425, #bf2e2e2e, #bf2e2e2e, #d2a1a1a1, #bf909090 +inactive_colors=#ffc2c2c2, #ff4a4a4a, #ff555555, #ff505050, #ff4f4f4f, #ff4d4d4d, #ffa3a3a3, #ffe7e7e7, #ffc0c0c0, #ff2e2e2e, #ff1b1b1b, #ff1b1b1b, #ff63d0df, #ff3f3f3f, #ff63d0df, #ff5bb2be, #ff1f2425, #ff2e2e2e, #ff2e2e2e, #ffa3a3a3, #ff777777 diff --git a/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt56ct_output__tests__light_default_qpalette.snap b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt56ct_output__tests__light_default_qpalette.snap new file mode 100644 index 00000000..c79b2c55 --- /dev/null +++ b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt56ct_output__tests__light_default_qpalette.snap @@ -0,0 +1,10 @@ +--- +source: cosmic-theme/src/output/qt56ct_output.rs +expression: light_default_qpalette +--- +# GENERATED BY COSMIC + +[ColorScheme] +active_colors=#ff121212, #ffc3c3c3, #ffbababa, #ffbebebe, #ffb3b3b3, #ffbbbbbb, #ff272727, #ffd7d7d7, #ff272727, #fff5f5f5, #ffd7d7d7, #ff121212, #ff00525a, #fff6f6f6, #ff00525a, #ff317379, #ffccd0d1, #fff5f5f5, #fff5f5f5, #ff272727, #ff8e8e8e +disabled_colors=#e62b2b2b, #8fc9c9c9, #a99b9b9b, #a4a0a0a0, #a9929292, #a49b9b9b, #d2535353, #ffd7d7d7, #d2535353, #bff5f5f5, #ffd7d7d7, #ff121212, #ff00525a, #bff6f6f6, #bf526d70, #bf72888a, #ffccd0d1, #bff5f5f5, #bff5f5f5, #d2535353, #bf6c6c6c +inactive_colors=#ff3f3f3f, #ffc3c3c3, #ffbababa, #ffbebebe, #ffb3b3b3, #ffbbbbbb, #ff505050, #ffd7d7d7, #ff272727, #fff5f5f5, #ffd7d7d7, #ff121212, #ff00525a, #fff6f6f6, #ff00525a, #ff317379, #ffccd0d1, #fff5f5f5, #fff5f5f5, #ff505050, #ff8e8e8e diff --git a/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__dark_default_kcolorscheme.snap b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__dark_default_kcolorscheme.snap new file mode 100644 index 00000000..c50f95dc --- /dev/null +++ b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__dark_default_kcolorscheme.snap @@ -0,0 +1,157 @@ +--- +source: cosmic-theme/src/output/qt_output.rs +expression: dark_default_kcolorscheme +--- +# GENERATED BY COSMIC + +[ColorEffects:Disabled] +Color=43,43,43 +ColorAmount=0 +ColorEffect=0 +ContrastAmount=0.65 +ContrastEffect=1 +IntensityAmount=0.1 +IntensityEffect=2 + +[ColorEffects:Inactive] +ChangeSelectionColor=false +Enable=false +Color=27,27,27 +ColorAmount=0.025 +ColorEffect=2 +ContrastAmount=0.1 +ContrastEffect=2 +IntensityAmount=0 +IntensityEffect=0 + +[Colors:Button] +BackgroundAlternate=99,208,223 +BackgroundNormal=60,60,60 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=99,208,223 +ForegroundInactive=211,211,211 +ForegroundLink=99,208,223 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=231,231,231 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[Colors:Complementary] +BackgroundAlternate=99,208,223 +BackgroundNormal=27,27,27 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=99,208,223 +ForegroundInactive=211,211,211 +ForegroundLink=99,208,223 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=231,231,231 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[Colors:Header] +BackgroundAlternate=31,36,37 +BackgroundNormal=27,27,27 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=99,208,223 +ForegroundInactive=211,211,211 +ForegroundLink=99,208,223 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=231,231,231 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[Colors:Header][Inactive] +BackgroundAlternate=31,36,37 +BackgroundNormal=27,27,27 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=99,208,223 +ForegroundInactive=211,211,211 +ForegroundLink=99,208,223 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=231,231,231 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[Colors:Selection] +BackgroundAlternate=63,118,125 +BackgroundNormal=99,208,223 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=67,67,67 +ForegroundInactive=83,138,145 +ForegroundLink=27,27,27 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=67,67,67 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[Colors:Tooltip] +BackgroundAlternate=49,55,55 +BackgroundNormal=46,46,46 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=99,208,223 +ForegroundInactive=211,211,211 +ForegroundLink=99,208,223 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=231,231,231 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[Colors:View] +BackgroundAlternate=49,55,55 +BackgroundNormal=46,46,46 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=99,208,223 +ForegroundInactive=211,211,211 +ForegroundLink=99,208,223 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=231,231,231 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[Colors:Window] +BackgroundAlternate=31,36,37 +BackgroundNormal=27,27,27 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=99,208,223 +ForegroundInactive=211,211,211 +ForegroundLink=99,208,223 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=231,231,231 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[General] +ColorScheme=CosmicDark +Name=COSMIC Dark +shadeSortColumn=true + +[Icons] +Theme=breeze-dark + +[KDE] +contrast=4 +widgetStyle=qt6ct-style + +[WM] +activeBackground=27,27,27 +activeBlend=99,208,223 +activeForeground=99,208,223 +inactiveBackground=27,27,27 +inactiveBlend=99,208,223 +inactiveForeground=99,208,223 diff --git a/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__light_default_kcolorscheme.snap b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__light_default_kcolorscheme.snap new file mode 100644 index 00000000..ae2bcb66 --- /dev/null +++ b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__light_default_kcolorscheme.snap @@ -0,0 +1,157 @@ +--- +source: cosmic-theme/src/output/qt_output.rs +expression: light_default_kcolorscheme +--- +# GENERATED BY COSMIC + +[ColorEffects:Disabled] +Color=194,194,194 +ColorAmount=0 +ColorEffect=0 +ContrastAmount=0.65 +ContrastEffect=1 +IntensityAmount=0.1 +IntensityEffect=2 + +[ColorEffects:Inactive] +ChangeSelectionColor=false +Enable=false +Color=215,215,215 +ColorAmount=0.025 +ColorEffect=2 +ContrastAmount=0.1 +ContrastEffect=2 +IntensityAmount=0 +IntensityEffect=0 + +[Colors:Button] +BackgroundAlternate=0,82,90 +BackgroundNormal=173,173,173 +DecorationFocus=0,82,90 +DecorationHover=0,82,90 +ForegroundActive=0,82,90 +ForegroundInactive=38,38,38 +ForegroundLink=0,82,90 +ForegroundNegative=137,4,24 +ForegroundNeutral=121,44,0 +ForegroundNormal=18,18,18 +ForegroundPositive=0,87,44 +ForegroundVisited=0,82,90 + +[Colors:Complementary] +BackgroundAlternate=99,208,223 +BackgroundNormal=27,27,27 +DecorationFocus=99,208,223 +DecorationHover=99,208,223 +ForegroundActive=99,208,223 +ForegroundInactive=211,211,211 +ForegroundLink=99,208,223 +ForegroundNegative=255,160,154 +ForegroundNeutral=255,163,125 +ForegroundNormal=231,231,231 +ForegroundPositive=94,219,140 +ForegroundVisited=99,208,223 + +[Colors:Header] +BackgroundAlternate=204,208,209 +BackgroundNormal=215,215,215 +DecorationFocus=0,82,90 +DecorationHover=0,82,90 +ForegroundActive=0,82,90 +ForegroundInactive=38,38,38 +ForegroundLink=0,82,90 +ForegroundNegative=137,4,24 +ForegroundNeutral=121,44,0 +ForegroundNormal=18,18,18 +ForegroundPositive=0,87,44 +ForegroundVisited=0,82,90 + +[Colors:Header][Inactive] +BackgroundAlternate=204,208,209 +BackgroundNormal=215,215,215 +DecorationFocus=0,82,90 +DecorationHover=0,82,90 +ForegroundActive=0,82,90 +ForegroundInactive=38,38,38 +ForegroundLink=0,82,90 +ForegroundNegative=137,4,24 +ForegroundNeutral=121,44,0 +ForegroundNormal=18,18,18 +ForegroundPositive=0,87,44 +ForegroundVisited=0,82,90 + +[Colors:Selection] +BackgroundAlternate=108,149,152 +BackgroundNormal=0,82,90 +DecorationFocus=0,82,90 +DecorationHover=0,82,90 +ForegroundActive=246,246,246 +ForegroundInactive=123,164,168 +ForegroundLink=215,215,215 +ForegroundNegative=137,4,24 +ForegroundNeutral=121,44,0 +ForegroundNormal=246,246,246 +ForegroundPositive=0,87,44 +ForegroundVisited=0,82,90 + +[Colors:Tooltip] +BackgroundAlternate=233,237,237 +BackgroundNormal=245,245,245 +DecorationFocus=0,82,90 +DecorationHover=0,82,90 +ForegroundActive=0,82,90 +ForegroundInactive=38,38,38 +ForegroundLink=0,82,90 +ForegroundNegative=137,4,24 +ForegroundNeutral=121,44,0 +ForegroundNormal=18,18,18 +ForegroundPositive=0,87,44 +ForegroundVisited=0,82,90 + +[Colors:View] +BackgroundAlternate=233,237,237 +BackgroundNormal=245,245,245 +DecorationFocus=0,82,90 +DecorationHover=0,82,90 +ForegroundActive=0,82,90 +ForegroundInactive=38,38,38 +ForegroundLink=0,82,90 +ForegroundNegative=137,4,24 +ForegroundNeutral=121,44,0 +ForegroundNormal=18,18,18 +ForegroundPositive=0,87,44 +ForegroundVisited=0,82,90 + +[Colors:Window] +BackgroundAlternate=204,208,209 +BackgroundNormal=215,215,215 +DecorationFocus=0,82,90 +DecorationHover=0,82,90 +ForegroundActive=0,82,90 +ForegroundInactive=38,38,38 +ForegroundLink=0,82,90 +ForegroundNegative=137,4,24 +ForegroundNeutral=121,44,0 +ForegroundNormal=18,18,18 +ForegroundPositive=0,87,44 +ForegroundVisited=0,82,90 + +[General] +ColorScheme=CosmicLight +Name=COSMIC Light +shadeSortColumn=true + +[Icons] +Theme=breeze + +[KDE] +contrast=4 +widgetStyle=qt6ct-style + +[WM] +activeBackground=215,215,215 +activeBlend=215,215,215 +activeForeground=0,82,90 +inactiveBackground=215,215,215 +inactiveBlend=215,215,215 +inactiveForeground=0,82,90 diff --git a/cosmic-theme/src/output/vs_code.rs b/cosmic-theme/src/output/vs_code.rs new file mode 100644 index 00000000..43c36bb6 --- /dev/null +++ b/cosmic-theme/src/output/vs_code.rs @@ -0,0 +1,312 @@ +use serde::{Deserialize, Serialize}; + +use crate::Theme; + +use super::{OutputError, to_hex}; + +/// Represents the workbench.colorCustomizations section of a VS Code settings.json file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VsTheme { + #[serde(rename = "editor.background")] + editor_background: String, + #[serde(rename = "sideBar.background")] + sidebar_background: String, + #[serde(rename = "activityBar.background")] + activity_bar_background: String, + #[serde(rename = "notificationCenterHeader.background")] + notification_center_header_background: String, + #[serde(rename = "notifications.background")] + notifications_background: String, + #[serde(rename = "activityBarTop.activeBackground")] + activity_bar_top_active_background: String, + #[serde(rename = "editorGroupHeader.tabsBackground")] + editor_group_header_tabs_background: String, + #[serde(rename = "editorGroupHeader.noTabsBackground")] + editor_group_header_no_tabs_background: String, + #[serde(rename = "titleBar.activeBackground")] + title_bar_active_background: String, + #[serde(rename = "titleBar.inactiveBackground")] + title_bar_inactive_background: String, + #[serde(rename = "statusBar.background")] + status_bar_background: String, + #[serde(rename = "statusBar.noFolderBackground")] + status_bar_no_folder_background: String, + #[serde(rename = "statusBar.debuggingBackground")] + status_bar_debugging_background: String, + #[serde(rename = "tab.activeBackground")] + tab_active_background: String, + #[serde(rename = "tab.activeBorder")] + tab_active_border: String, + #[serde(rename = "tab.activeBorderTop")] + tab_active_border_top: String, + #[serde(rename = "tab.hoverBackground")] + tab_hover_background: String, + #[serde(rename = "quickInput.background")] + quick_input_background: String, + #[serde(rename = "tab.inactiveBackground")] + tab_inactive_background: String, + #[serde(rename = "sideBarSectionHeader.background")] + side_bar_section_header_background: String, + #[serde(rename = "list.focusOutline")] + list_focus_outline: String, + #[serde(rename = "banner.background")] + banner_background: String, + #[serde(rename = "breadcrumb.background")] + breadcrumb_background: String, + #[serde(rename = "commandCenter.background")] + command_center_background: String, + #[serde(rename = "terminal.background")] + terminal_background: String, + #[serde(rename = "menu.background")] + menu_background: String, + #[serde(rename = "panel.background")] + panel_background: String, + #[serde(rename = "peekViewEditorGutter.background")] + peek_view_editor_gutter_background: String, + #[serde(rename = "peekViewResult.background")] + peek_view_result_background: String, + #[serde(rename = "peekViewTitle.background")] + peek_view_title_background: String, + #[serde(rename = "peekViewEditor.background")] + peek_view_editor_background: String, + #[serde(rename = "peekViewResult.selectionBackground")] + peek_view_result_selection_background: String, + #[serde(rename = "editorWidget.background")] + editor_widget_background: String, + #[serde(rename = "editorSuggestWidget.background")] + editor_suggest_widget_background: String, + #[serde(rename = "editorHoverWidget.background")] + editor_hover_widget_background: String, + #[serde(rename = "input.background")] + input_background: String, + #[serde(rename = "dropdown.background")] + dropdown_background: String, + #[serde(rename = "settings.checkboxBackground")] + settings_checkbox_background: String, + #[serde(rename = "settings.textInputBackground")] + settings_text_input_background: String, + #[serde(rename = "settings.numberInputBackground")] + settings_number_input_background: String, + #[serde(rename = "settings.dropdownBackground")] + settings_dropdown_background: String, + #[serde(rename = "sideBar.dropBackground")] + side_bar_drop_background: String, + #[serde(rename = "list.activeSelectionBackground")] + list_active_selection_background: String, + #[serde(rename = "list.inactiveSelectionBackground")] + list_inactive_selection_background: String, + #[serde(rename = "list.focusBackground")] + list_focus_background: String, + #[serde(rename = "list.hoverBackground")] + list_hover_background: String, + + // text colors + #[serde(rename = "editor.foreground")] + editor_foreground: String, + #[serde(rename = "editorLineNumber.foreground")] + editor_line_number_foreground: String, + #[serde(rename = "editorCursor.foreground")] + editor_cursor_foreground: String, + #[serde(rename = "sideBar.foreground")] + side_bar_foreground: String, + #[serde(rename = "activityBar.foreground")] + activity_bar_foreground: String, + #[serde(rename = "statusBar.foreground")] + status_bar_foreground: String, + #[serde(rename = "tab.activeForeground")] + tab_active_foreground: String, + #[serde(rename = "tab.inactiveForeground")] + tab_inactive_foreground: String, + #[serde(rename = "editorGroupHeader.tabsForeground")] + editor_group_header_tabs_foreground: String, + #[serde(rename = "sideBarSectionHeader.foreground")] + side_bar_section_header_foreground: String, + #[serde(rename = "statusBar.debuggingForeground")] + status_bar_debugging_foreground: String, + #[serde(rename = "statusBar.noFolderForeground")] + status_bar_no_folder_foreground: String, + #[serde(rename = "editorWidget.foreground")] + editor_widget_foreground: String, + #[serde(rename = "editorSuggestWidget.foreground")] + editor_suggest_widget_foreground: String, + #[serde(rename = "editorHoverWidget.foreground")] + editor_hover_widget_foreground: String, + #[serde(rename = "input.foreground")] + input_foreground: String, + #[serde(rename = "dropdown.foreground")] + dropdown_foreground: String, + #[serde(rename = "terminal.foreground")] + terminal_foreground: String, + #[serde(rename = "menu.foreground")] + menu_foreground: String, + #[serde(rename = "panel.foreground")] + panel_foreground: String, + #[serde(rename = "peekViewEditorGutter.foreground")] + peek_view_editor_gutter_foreground: String, + #[serde(rename = "peekViewResult.selectionForeground")] + peek_view_result_selection_foreground: String, + #[serde(rename = "inputOption.activeBorder")] + input_option_active_border: String, + + // accent colors + #[serde(rename = "activityBarBadge.background")] + activity_bar_badge_background: String, + #[serde(rename = "statusBar.debuggingBorder")] + status_bar_debugging_border: String, + #[serde(rename = "button.background")] + button_background: String, + #[serde(rename = "button.hoverBackground")] + button_hover_background: String, + #[serde(rename = "statusBarItem.remoteBackground")] + status_bar_item_remote_background: String, + + // accent fg colors + #[serde(rename = "activityBarBadge.foreground")] + activity_bar_badge_foreground: String, + #[serde(rename = "button.foreground")] + button_foreground: String, + #[serde(rename = "textLink.foreground")] + text_link_foreground: String, + #[serde(rename = "textLink.activeForeground")] + text_link_active_foreground: String, + #[serde(rename = "peekView.border")] + peek_view_border: String, + #[serde(rename = "settings.checkboxForeground")] + settings_checkbox_foreground: String, +} + +impl From for VsTheme { + fn from(theme: Theme) -> Self { + Self { + editor_background: format!("#{}", to_hex(theme.background.base)), + sidebar_background: format!("#{}", to_hex(theme.primary.base)), + activity_bar_background: format!("#{}", to_hex(theme.primary.base)), + notification_center_header_background: format!("#{}", to_hex(theme.background.base)), + notifications_background: format!("#{}", to_hex(theme.background.base)), + activity_bar_top_active_background: format!("#{}", to_hex(theme.primary.base)), + editor_group_header_tabs_background: format!("#{}", to_hex(theme.background.base)), + editor_group_header_no_tabs_background: format!("#{}", to_hex(theme.background.base)), + title_bar_active_background: format!("#{}", to_hex(theme.background.component.base)), + title_bar_inactive_background: format!( + "#{}", + to_hex(theme.background.component.disabled) + ), + status_bar_background: format!("#{}", to_hex(theme.background.base)), + status_bar_no_folder_background: format!("#{}", to_hex(theme.background.base)), + status_bar_debugging_background: format!("#{}", to_hex(theme.background.base)), + tab_active_background: format!("#{}", to_hex(theme.primary.component.pressed)), + tab_active_border: format!("#{}", to_hex(theme.accent.base)), + tab_active_border_top: format!("#{}", to_hex(theme.accent.base)), + tab_hover_background: format!("#{}", to_hex(theme.primary.component.hover)), + tab_inactive_background: format!("#{}", to_hex(theme.primary.component.base)), + quick_input_background: format!("#{}", to_hex(theme.primary.base)), + side_bar_section_header_background: format!("#{}", to_hex(theme.primary.base)), + banner_background: format!("#{}", to_hex(theme.primary.base)), + breadcrumb_background: format!("#{}", to_hex(theme.primary.base)), + command_center_background: format!("#{}", to_hex(theme.primary.base)), + terminal_background: format!("#{}", to_hex(theme.primary.base)), + menu_background: format!("#{}", to_hex(theme.primary.base)), + panel_background: format!("#{}", to_hex(theme.primary.base)), + peek_view_editor_gutter_background: format!("#{}", to_hex(theme.background.base)), + peek_view_result_background: format!("#{}", to_hex(theme.background.base)), + peek_view_title_background: format!("#{}", to_hex(theme.background.base)), + peek_view_editor_background: format!("#{}", to_hex(theme.background.base)), + peek_view_result_selection_background: format!("#{}", to_hex(theme.background.base)), + editor_widget_background: format!("#{}", to_hex(theme.background.base)), + editor_suggest_widget_background: format!("#{}", to_hex(theme.background.base)), + editor_hover_widget_background: format!("#{}", to_hex(theme.background.base)), + input_background: format!("#{}", to_hex(theme.background.base)), + dropdown_background: format!("#{}", to_hex(theme.background.base)), + settings_checkbox_background: format!("#{}", to_hex(theme.background.base)), + settings_text_input_background: format!("#{}", to_hex(theme.background.base)), + settings_number_input_background: format!("#{}", to_hex(theme.background.base)), + settings_dropdown_background: format!("#{}", to_hex(theme.background.base)), + side_bar_drop_background: format!("#{}", to_hex(theme.background.base)), + list_active_selection_background: format!("#{}", to_hex(theme.primary.base)), + list_inactive_selection_background: format!("#{}", to_hex(theme.primary.base)), + list_focus_background: format!("#{}", to_hex(theme.primary.base)), + list_hover_background: format!("#{}", to_hex(theme.primary.base)), + editor_foreground: format!("#{}", to_hex(theme.background.on)), + editor_line_number_foreground: format!("#{}", to_hex(theme.background.on)), + editor_cursor_foreground: format!("#{}", to_hex(theme.background.on)), + side_bar_foreground: format!("#{}", to_hex(theme.primary.on)), + activity_bar_foreground: format!("#{}", to_hex(theme.primary.on)), + status_bar_foreground: format!("#{}", to_hex(theme.primary.on)), + tab_active_foreground: format!("#{}", to_hex(theme.primary.on)), + tab_inactive_foreground: format!("#{}", to_hex(theme.primary.on)), + editor_group_header_tabs_foreground: format!("#{}", to_hex(theme.primary.on)), + side_bar_section_header_foreground: format!("#{}", to_hex(theme.primary.on)), + status_bar_debugging_foreground: format!("#{}", to_hex(theme.primary.on)), + status_bar_no_folder_foreground: format!("#{}", to_hex(theme.primary.on)), + editor_widget_foreground: format!("#{}", to_hex(theme.primary.on)), + editor_suggest_widget_foreground: format!("#{}", to_hex(theme.primary.on)), + editor_hover_widget_foreground: format!("#{}", to_hex(theme.primary.on)), + input_foreground: format!("#{}", to_hex(theme.primary.on)), + dropdown_foreground: format!("#{}", to_hex(theme.primary.on)), + terminal_foreground: format!("#{}", to_hex(theme.primary.on)), + menu_foreground: format!("#{}", to_hex(theme.primary.on)), + panel_foreground: format!("#{}", to_hex(theme.primary.on)), + peek_view_editor_gutter_foreground: format!("#{}", to_hex(theme.primary.on)), + peek_view_result_selection_foreground: format!("#{}", to_hex(theme.primary.on)), + input_option_active_border: format!("#{}", to_hex(theme.accent.base)), + activity_bar_badge_background: format!("#{}", to_hex(theme.accent.base)), + activity_bar_badge_foreground: format!("#{}", to_hex(theme.accent.on)), + status_bar_debugging_border: format!("#{}", to_hex(theme.accent.base)), + list_focus_outline: format!("#{}", to_hex(theme.accent.base)), + button_background: format!("#{}", to_hex(theme.accent_button.base)), + button_hover_background: format!("#{}", to_hex(theme.accent_button.hover)), + status_bar_item_remote_background: format!("#{}", to_hex(theme.accent.base)), + button_foreground: format!("#{}", to_hex(theme.accent_button.on)), + text_link_foreground: format!("#{}", to_hex(theme.accent.base)), + text_link_active_foreground: format!("#{}", to_hex(theme.accent.base)), + peek_view_border: format!("#{}", to_hex(theme.accent.base)), + settings_checkbox_foreground: format!("#{}", to_hex(theme.accent.base)), + } + } +} + +impl Theme { + #[cold] + pub fn apply_vs_code(self) -> Result<(), OutputError> { + let vs_theme = VsTheme::from(self); + let mut config_dir = dirs::config_dir().ok_or(OutputError::MissingConfigDir)?; + config_dir.extend(["Code", "User"]); + let vs_code_dir = config_dir; + if !vs_code_dir.exists() { + std::fs::create_dir_all(&vs_code_dir).map_err(OutputError::Io)?; + } + + // just add the json entry for workbench.colorCustomizations + let settings_file = vs_code_dir.join("settings.json"); + let settings = std::fs::read_to_string(&settings_file).unwrap_or_default(); + let mut settings: serde_json::Value = serde_json::from_str(&settings)?; + settings["workbench.colorCustomizations"] = serde_json::to_value(vs_theme).unwrap(); + settings["window.autoDetectColorScheme"] = serde_json::Value::Bool(true); + std::fs::write( + &settings_file, + serde_json::to_string_pretty(&settings).unwrap(), + ) + .map_err(OutputError::Io)?; + + Ok(()) + } + + #[cold] + pub fn reset_vs_code() -> Result<(), OutputError> { + let mut config_dir = dirs::config_dir().ok_or(OutputError::MissingConfigDir)?; + config_dir.extend(["Code", "User", "settings.json"]); + let settings_file = config_dir; + // just remove the json entry for workbench.colorCustomizations + let settings = std::fs::read_to_string(&settings_file).unwrap_or_default(); + let mut settings: serde_json::Value = serde_json::from_str(&settings).unwrap_or_default(); + settings["workbench.colorCustomizations"] = serde_json::Value::Null; + + std::fs::write( + &settings_file, + serde_json::to_string_pretty(&settings).unwrap(), + ) + .map_err(OutputError::Io)?; + + Ok(()) + } +} diff --git a/cosmic-theme/src/steps.rs b/cosmic-theme/src/steps.rs index 6487ba85..6ebf1015 100644 --- a/cosmic-theme/src/steps.rs +++ b/cosmic-theme/src/steps.rs @@ -1,7 +1,7 @@ use std::num::NonZeroUsize; use almost::equal; -use palette::{convert::FromColorUnclamped, ClampAssign, FromColor, Oklcha, Srgb, Srgba}; +use palette::{ClampAssign, FromColor, Lch, Oklcha, Srgb, Srgba, convert::FromColorUnclamped}; /// Get an array of 100 colors with a specific hue and chroma /// over the full range of lightness. @@ -35,26 +35,51 @@ pub fn get_index(base_index: usize, steps: usize, step_len: usize, is_dark: bool pub fn get_surface_color( base_index: usize, steps: usize, - step_array: &Vec, + step_array: &[Srgba], mut is_dark: bool, fallback: &Srgba, ) -> Srgba { assert!(step_array.len() == 100); - if !is_dark && base_index >= 88 { - is_dark = true; + + is_dark = is_dark || base_index < 91; + + *get_index(base_index, steps, step_array.len(), is_dark) + .and_then(|i| step_array.get(i)) + .unwrap_or(fallback) +} + +/// get surface color given a base and some steps +#[must_use] +pub fn get_small_widget_color( + base_index: usize, + steps: usize, + step_array: &[Srgba], + fallback: &Srgba, +) -> Srgba { + assert!(step_array.len() == 100); + + let is_dark = base_index <= 40 || (base_index > 51 && base_index < 65); + + let res = *get_index(base_index, steps, step_array.len(), is_dark) + .and_then(|i| step_array.get(i)) + .unwrap_or(fallback); + + let mut lch = Lch::from_color(res); + if lch.chroma / Lch::::max_chroma() > 0.03 { + lch.chroma = 0.03 * Lch::::max_chroma(); + lch.clamp_assign(); + Srgba::from_color(lch) + } else { + res } - get_index(base_index, steps, step_array.len(), is_dark) - .and_then(|i| step_array.get(i).cloned()) - .unwrap_or_else(|| fallback.to_owned()) } /// get text color given a base background color pub fn get_text( base_index: usize, - step_array: &Vec, - is_dark: bool, + step_array: &[Srgba], fallback: &Srgba, - tint_array: Option<&Vec>, + tint_array: Option<&[Srgba]>, ) -> Srgba { assert!(step_array.len() == 100); let step_array = if let Some(tint_array) = tint_array { @@ -63,16 +88,14 @@ pub fn get_text( } else { step_array }; - let Some(index) = get_index(base_index, 70, step_array.len(), is_dark) - .or_else(|| get_index(base_index, 50, step_array.len(), is_dark)) - else { - return fallback.to_owned(); - }; - step_array - .get(index) - .cloned() - .unwrap_or_else(|| fallback.to_owned()) + let is_dark = base_index < 60; + + let index = get_index(base_index, 70, step_array.len(), is_dark) + .or_else(|| get_index(base_index, 50, step_array.len(), is_dark)) + .unwrap_or(if is_dark { 99 } else { 0 }); + + *step_array.get(index).unwrap_or(fallback) } /// get the index into the steps array for a given color @@ -122,7 +145,6 @@ pub fn is_valid_srgb(c: Srgba) -> bool { #[cfg(test)] mod tests { - use almost::equal; use palette::{OklabHue, Srgba}; use super::{is_valid_srgb, oklch_to_srgba_nearest_chroma}; @@ -150,57 +172,57 @@ mod tests { fn test_conversion_boundaries() { let c1 = palette::Oklcha::new(0.0, 0.288, OklabHue::from_degrees(0.0), 1.0); let srgb = oklch_to_srgba_nearest_chroma(c1); - equal(srgb.red, 0.0); - equal(srgb.blue, 0.0); - equal(srgb.green, 0.0); + almost::zero(srgb.red); + almost::zero(srgb.blue); + almost::zero(srgb.green); let c1 = palette::Oklcha::new(1.0, 0.288, OklabHue::from_degrees(0.0), 1.0); let srgb = oklch_to_srgba_nearest_chroma(c1); - equal(srgb.red, 1.0); - equal(srgb.blue, 1.0); - equal(srgb.green, 1.0); + almost::equal(srgb.red, 1.0); + almost::equal(srgb.blue, 1.0); + almost::equal(srgb.green, 1.0); } #[test] fn test_conversion_colors() { let c1 = palette::Oklcha::new(0.4608, 0.11111, OklabHue::new(57.31), 1.0); let srgb = oklch_to_srgba_nearest_chroma(c1).into_format::(); - assert!(srgb.red == 133); - assert!(srgb.green == 69); - assert!(srgb.blue == 0); + assert_eq!(srgb.red, 133); + assert_eq!(srgb.green, 69); + assert_eq!(srgb.blue, 0); let c1 = palette::Oklcha::new(0.30, 0.08, OklabHue::new(35.0), 1.0); let srgb = oklch_to_srgba_nearest_chroma(c1).into_format::(); - assert!(srgb.red == 78); - assert!(srgb.green == 27); - assert!(srgb.blue == 15); + assert_eq!(srgb.red, 78); + assert_eq!(srgb.green, 27); + assert_eq!(srgb.blue, 15); let c1 = palette::Oklcha::new(0.757, 0.146, OklabHue::new(301.2), 1.0); let srgb = oklch_to_srgba_nearest_chroma(c1).into_format::(); - assert!(srgb.red == 192); - assert!(srgb.green == 153); - assert!(srgb.blue == 253); + assert_eq!(srgb.red, 192); + assert_eq!(srgb.green, 153); + assert_eq!(srgb.blue, 253); } #[test] fn test_conversion_fallback_colors() { let c1 = palette::Oklcha::new(0.70, 0.284, OklabHue::new(35.0), 1.0); let srgb = oklch_to_srgba_nearest_chroma(c1).into_format::(); - assert!(srgb.red == 255); - assert!(srgb.green == 103); - assert!(srgb.blue == 65); + assert_eq!(srgb.red, 255); + assert_eq!(srgb.green, 102); + assert_eq!(srgb.blue, 65); let c1 = palette::Oklcha::new(0.757, 0.239, OklabHue::new(301.2), 1.0); let srgb = oklch_to_srgba_nearest_chroma(c1).into_format::(); - assert!(srgb.red == 193); - assert!(srgb.green == 152); - assert!(srgb.blue == 255); + assert_eq!(srgb.red, 193); + assert_eq!(srgb.green, 152); + assert_eq!(srgb.blue, 255); let c1 = palette::Oklcha::new(0.163, 0.333, OklabHue::new(141.0), 1.0); let srgb = oklch_to_srgba_nearest_chroma(c1).into_format::(); - assert!(srgb.red == 1); - assert!(srgb.green == 19); - assert!(srgb.blue == 0); + assert_eq!(srgb.red, 1); + assert_eq!(srgb.green, 19); + assert_eq!(srgb.blue, 0); } } diff --git a/cosmic-theme/src/util.rs b/cosmic-theme/src/util.rs deleted file mode 100644 index 762017d6..00000000 --- a/cosmic-theme/src/util.rs +++ /dev/null @@ -1,33 +0,0 @@ -use csscolorparser::Color; -use palette::Srgba; -use serde::{Deserialize, Serialize}; - -/// utility wrapper for serializing and deserializing colors with arbitrary CSS -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] -pub struct CssColor { - c: Color, -} - -impl From for CssColor { - fn from(c: Srgba) -> Self { - Self { - c: Color { - r: c.red as f64, - g: c.green as f64, - b: c.blue as f64, - a: c.alpha as f64, - }, - } - } -} - -impl Into for CssColor { - fn into(self) -> Srgba { - Srgba::new( - self.c.r as f32, - self.c.g as f32, - self.c.b as f32, - self.c.a as f32, - ) - } -} diff --git a/examples/README.md b/examples/README.md index beef9c99..a1c0e29f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,10 +1,11 @@ -## `design-demo` +# Examples -Showcase of all widgets and their styled variations for the purpose of demonstrating and -fine-tuning our design system. +## `applet` + +Demonstrates how to create an applet. ```sh -just run cosmic-design demo +just run applet ``` ## `application` @@ -12,7 +13,64 @@ just run cosmic-design demo Start here as a template for creating an application with libcosmic's application API. ```sh -just run cosmic-design demo +just run application +``` + +## `calendar` + +Demonstrates how to use the calendar widget. + +```sh +just run calendar +``` + +## `config` + +Demonstrates how to use the configuration system. There is no GUI in this +example. + +```sh +just run config +``` + +## `context-menu` + +Demonstrates how to use the context menu widget. + +```sh +just run context-menu +``` + +## `image-button` + +Demonstrates how to use the image-button widget. + +```sh +just run image-button +``` + +## `menu` + +Demonstrates how use the menu widget. + +```sh +just run menu +``` + +## `multi-window` + +Demonstrates how to open multiple windows. + +```sh +just run multi-window +``` + +## `nav-context` + +Demonstrates how to use the navigation bar widget. + +```sh +just run nav-context ``` ## `open-dialog` @@ -21,4 +79,12 @@ Demonstrates how to create an open file dialog ```sh just run open-dialog -``` \ No newline at end of file +``` + +## `text-input` + +Demonstrates how to use the text input widgets. + +```sh +just run text-input +``` diff --git a/examples/about/Cargo.toml b/examples/about/Cargo.toml new file mode 100644 index 00000000..f980811c --- /dev/null +++ b/examples/about/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "about" +version = "0.1.0" +edition = "2021" + +[dependencies] +open = "5.3.3" + +[dependencies.libcosmic] +path = "../../" +features = [ + "debug", + "winit", + "tokio", + "xdg-portal", + "desktop", + "a11y", + "wayland", + "wgpu", + "single-instance", + "about", +] diff --git a/examples/about/src/main.rs b/examples/about/src/main.rs new file mode 100644 index 00000000..c25a9b9a --- /dev/null +++ b/examples/about/src/main.rs @@ -0,0 +1,148 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Application API example + +use cosmic::app::context_drawer::{self, ContextDrawer}; +use cosmic::app::{Core, Settings, Task}; +use cosmic::executor; +use cosmic::iced::{alignment, Length, Size}; +use cosmic::prelude::*; +use cosmic::widget::{self, about::About, nav_bar}; + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + let settings = Settings::default() + .size(Size::new(1024., 768.)); + + cosmic::app::run::(settings, ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + ToggleAbout, + Open(String), +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + nav_model: nav_bar::Model, + about: About, + show_about: bool, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.AboutDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _flags: Self::Flags) -> (Self, Task) { + let nav_model = nav_bar::Model::default(); + + let about = About::default() + .name("About Demo") + .icon(widget::icon::from_name(Self::APP_ID)) + .version("0.1.0") + .author("System76") + .license("GPL-3.0-only") + .license_url("https://choosealicense.com/licenses/gpl-3.0/") + .developers([("Michael Murphy", "info@system76.com")]) + .links([ + ("Website", "https://system76.com/cosmic"), + ("Repository", "https://github.com/pop-os/libcosmic"), + ("Support", "https://github.com/pop-os/libcosmic/issues"), + ]); + + let mut app = App { + core, + nav_model, + about, + show_about: false, + }; + + app.set_header_title("COSMIC About Example".into()); + let command = app.set_window_title( + "COSMIC About Example".into(), + app.core.main_window_id().unwrap(), + ); + + (app, command) + } + + /// Allows COSMIC to integrate with your application's [`nav_bar::Model`]. + fn nav_model(&self) -> Option<&nav_bar::Model> { + Some(&self.nav_model) + } + + /// Called when a navigation item is selected. + fn on_nav_select(&mut self, id: nav_bar::Id) -> Task { + self.nav_model.activate(id); + Task::none() + } + + fn context_drawer(&self) -> Option> { + self.show_about.then(|| { + context_drawer::about( + &self.about, + |url| Message::Open(url.to_owned()), + Message::ToggleAbout, + ) + }) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::ToggleAbout => { + self.set_show_context(!self.core.window.show_context); + self.show_about = !self.show_about; + } + Message::Open(url) => match open::that_detached(url) { + Ok(_) => (), + Err(err) => eprintln!("Failed to open URL: {err}"), + }, + } + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element<'_, Self::Message> { + let show_about_button = widget::button::text("Show about").on_press(Message::ToggleAbout); + let centered = cosmic::widget::container( + widget::column::with_capacity(1) + .push(show_about_button) + .width(Length::Fill) + .height(Length::Shrink) + .align_x(alignment::Horizontal::Center), + ) + .width(Length::Fill) + .height(Length::Shrink) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center); + + Element::from(centered) + } +} diff --git a/examples/applet/Cargo.toml b/examples/applet/Cargo.toml index 965e30ac..13eff684 100644 --- a/examples/applet/Cargo.toml +++ b/examples/applet/Cargo.toml @@ -7,10 +7,12 @@ edition = "2021" [dependencies] once_cell = "1" -rust-embed = "8.0.0" +rust-embed = "8.11.0" tracing = "0.1" +env_logger = "0.10.2" +log = "0.4.29" [dependencies.libcosmic] -git = "https://github.com/pop-os/libcosmic" +path = "../../" default-features = false -features = ["applet", "tokio", "wayland"] +features = ["applet-token"] diff --git a/examples/applet/src/main.rs b/examples/applet/src/main.rs index 28e893a3..4ff0c0c5 100644 --- a/examples/applet/src/main.rs +++ b/examples/applet/src/main.rs @@ -3,5 +3,10 @@ use crate::window::Window; mod window; fn main() -> cosmic::iced::Result { - cosmic::applet::run::(true, ()) + let env = env_logger::Env::default() + .filter_or("MY_LOG_LEVEL", "warn") + .write_style_or("MY_LOG_STYLE", "always"); + + env_logger::init_from_env(env); + cosmic::applet::run::(()) } diff --git a/examples/applet/src/window.rs b/examples/applet/src/window.rs index 7a672559..22903eac 100644 --- a/examples/applet/src/window.rs +++ b/examples/applet/src/window.rs @@ -1,27 +1,41 @@ -use cosmic::app::Core; -use cosmic::iced::wayland::popup::{destroy_popup, get_popup}; +use cosmic::app::{Core, Task}; + +use cosmic::iced::core::window; use cosmic::iced::window::Id; -use cosmic::iced::{Command, Limits}; -use cosmic::iced_runtime::core::window; -use cosmic::iced_style::application; -use cosmic::widget::{list_column, settings, toggler}; -use cosmic::{Element, Theme}; +use cosmic::iced::{Length, Rectangle}; +use cosmic::surface::action::{app_popup, destroy_popup}; +use cosmic::widget::{dropdown::popup_dropdown, list_column, settings, toggler}; +use cosmic::Element; const ID: &str = "com.system76.CosmicAppletExample"; -#[derive(Default)] pub struct Window { core: Core, popup: Option, - id_ctr: u128, example_row: bool, + toggle: bool, + selected: Option, +} + +impl Default for Window { + fn default() -> Self { + Self { + core: Core::default(), + popup: None, + example_row: false, + toggle: false, + selected: None, + } + } } #[derive(Clone, Debug)] pub enum Message { - TogglePopup, PopupClosed(Id), ToggleExampleRow(bool), + Selected(usize), + Surface(cosmic::surface::Action), + Toggle(bool), } impl cosmic::Application for Window { @@ -38,72 +52,114 @@ impl cosmic::Application for Window { &mut self.core } - fn init( - core: Core, - _flags: Self::Flags, - ) -> (Self, Command>) { + fn init(core: Core, _flags: Self::Flags) -> (Self, Task) { let window = Window { core, ..Default::default() }; - (window, Command::none()) + (window, Task::none()) } fn on_close_requested(&self, id: window::Id) -> Option { Some(Message::PopupClosed(id)) } - fn update(&mut self, message: Self::Message) -> Command> { + fn update(&mut self, message: Message) -> Task { match message { - Message::TogglePopup => { - return if let Some(p) = self.popup.take() { - destroy_popup(p) - } else { - self.id_ctr += 1; - let new_id = Id(self.id_ctr); - self.popup.replace(new_id); - let mut popup_settings = - self.core - .applet - .get_popup_settings(Id(0), new_id, None, None, None); - popup_settings.positioner.size_limits = Limits::NONE - .max_width(372.0) - .min_width(300.0) - .min_height(200.0) - .max_height(1080.0); - get_popup(popup_settings) - } - } Message::PopupClosed(id) => { if self.popup.as_ref() == Some(&id) { self.popup = None; } } - Message::ToggleExampleRow(toggled) => self.example_row = toggled, - } - Command::none() + Message::ToggleExampleRow(toggled) => { + self.example_row = toggled; + } + Message::Surface(a) => { + return cosmic::task::message(cosmic::Action::Cosmic( + cosmic::app::Action::Surface(a), + )); + } + Message::Selected(i) => { + self.selected = Some(i); + } + Message::Toggle(v) => { + self.toggle = v; + } + }; + Task::none() } - fn view(&self) -> Element { - self.core + fn view(&self) -> Element { + let have_popup = self.popup.clone(); + let btn = self + .core .applet .icon_button("display-symbolic") - .on_press(Message::TogglePopup) - .into() + .on_press_with_rectangle(move |offset, bounds| { + if let Some(id) = have_popup { + Message::Surface(destroy_popup(id)) + } else { + Message::Surface(app_popup::( + move |state: &mut Window| { + let new_id = Id::unique(); + state.popup = Some(new_id); + let mut popup_settings = state.core.applet.get_popup_settings( + state.core.main_window_id().unwrap(), + new_id, + None, + None, + None, + ); + + popup_settings.positioner.anchor_rect = Rectangle { + x: (bounds.x - offset.x) as i32, + y: (bounds.y - offset.y) as i32, + width: bounds.width as i32, + height: bounds.height as i32, + }; + + popup_settings + }, + Some(Box::new(move |state: &Window| { + let content_list = list_column() + .padding(5) + .spacing(0) + .add(settings::item( + "Example row", + cosmic::widget::container( + toggler(state.example_row) + .on_toggle(Message::ToggleExampleRow), + ), + )) + .add(popup_dropdown( + &["1", "asdf", "hello", "test"], + state.selected, + Message::Selected, + state.popup.unwrap_or(Id::NONE), + Message::Surface, + |m| m, + )); + Element::from(state.core.applet.popup_container(content_list)) + .map(cosmic::Action::App) + })), + )) + } + }); + + Element::from(self.core.applet.applet_tooltip::( + btn, + "test", + self.popup.is_some(), + |a| Message::Surface(a), + None, + )) } - fn view_window(&self, _id: Id) -> Element { - let content_list = list_column().padding(5).spacing(0).add(settings::item( - "Example row", - toggler(None, self.example_row, |value| { - Message::ToggleExampleRow(value) - }), - )); - - self.core.applet.popup_container(content_list).into() + fn view_window(&self, _id: Id) -> Element { + "oops".into() } - fn style(&self) -> Option<::Style> { + fn style(&self) -> Option { Some(cosmic::applet::style()) } } diff --git a/examples/application/Cargo.toml b/examples/application/Cargo.toml index 33ba4505..7a6083e0 100644 --- a/examples/application/Cargo.toml +++ b/examples/application/Cargo.toml @@ -3,11 +3,23 @@ name = "application" version = "0.1.0" edition = "2021" +[features] +default = ["wayland"] +wayland = ["libcosmic/wayland"] + [dependencies] -tracing = "0.1.37" -tracing-subscriber = "0.3.17" +env_logger = "0.11" [dependencies.libcosmic] path = "../../" -default-features = false -features = ["debug", "winit", "tokio"] +features = [ + "debug", + "winit", + "tokio", + "xdg-portal", + "a11y", + "single-instance", + "surface-message", + "multi-window", + "wgpu", +] diff --git a/examples/application/src/main.rs b/examples/application/src/main.rs index d8abad60..f6e571e0 100644 --- a/examples/application/src/main.rs +++ b/examples/application/src/main.rs @@ -3,9 +3,15 @@ //! Application API example -use cosmic::app::{Command, Core, Settings}; +use cosmic::app::Settings; +use cosmic::iced::{Alignment, Length, Size}; +use cosmic::widget::menu::{self, KeyBind}; use cosmic::widget::nav_bar; -use cosmic::{executor, iced, ApplicationExt, Element}; +use cosmic::{executor, iced, prelude::*, widget, Core}; +use std::collections::HashMap; +use std::sync::LazyLock; + +static MENU_ID: LazyLock = LazyLock::new(|| iced::id::Id::new("menu_id")); #[derive(Clone, Copy)] pub enum Page { @@ -26,9 +32,32 @@ impl Page { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Action { + Hi, + Hi2, + Hi3, +} + +impl widget::menu::Action for Action { + type Message = Message; + + fn message(&self) -> Message { + match self { + Action::Hi => Message::Hi, + Action::Hi2 => Message::Hi2, + Action::Hi3 => Message::Hi3, + } + } +} + /// Runs application with these settings #[rustfmt::skip] fn main() -> Result<(), Box> { + + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + + let input = vec![ (Page::Page1, "🖖 Hello from libcosmic.".into()), (Page::Page2, "🌟 This is an example application.".into()), @@ -37,28 +66,34 @@ fn main() -> Result<(), Box> { ]; let settings = Settings::default() - .antialiasing(true) - .client_decorations(true) - .debug(false) - .default_icon_theme("Pop") - .default_text_size(16.0) - .scale_factor(1.0) - .size((1024, 768)) - .theme(cosmic::Theme::dark()); - - cosmic::app::run::(settings, input)?; - + .size(Size::new(1024., 768.)); + cosmic::app::run::(settings, input).unwrap(); Ok(()) } /// Messages that are used specifically by our [`App`]. #[derive(Clone, Debug)] -pub enum Message {} +pub enum Message { + Input1(String), + Input2(String), + Ignore, + ToggleHide, + Surface(cosmic::surface::Action), + Hi, + Hi2, + Hi3, + Tick, +} /// The [`App`] stores application-specific state. pub struct App { core: Core, nav_model: nav_bar::Model, + input_1: String, + input_2: String, + hidden: bool, + keybinds: HashMap, + progress: f32, } /// Implement [`cosmic::Application`] to integrate with COSMIC. @@ -83,8 +118,8 @@ impl cosmic::Application for App { &mut self.core } - /// Creates the application, and optionally emits command on initialize. - fn init(core: Core, input: Self::Flags) -> (Self, Command) { + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, input: Self::Flags) -> (Self, cosmic::app::Task) { let mut nav_model = nav_bar::Model::default(); for (title, content) in input { @@ -93,7 +128,15 @@ impl cosmic::Application for App { nav_model.activate_position(0); - let mut app = App { core, nav_model }; + let mut app = App { + core, + nav_model, + input_1: String::new(), + input_2: String::new(), + hidden: true, + keybinds: HashMap::new(), + progress: 0.0, + }; let command = app.update_title(); @@ -106,34 +149,209 @@ impl cosmic::Application for App { } /// Called when a navigation item is selected. - fn on_nav_select(&mut self, id: nav_bar::Id) -> Command { + fn on_nav_select(&mut self, id: nav_bar::Id) -> cosmic::app::Task { self.nav_model.activate(id); self.update_title() } /// Handle application events here. - fn update(&mut self, _message: Self::Message) -> Command { - Command::none() + fn update(&mut self, message: Self::Message) -> cosmic::app::Task { + match message { + Message::Input1(v) => { + self.input_1 = v; + } + Message::Input2(v) => { + self.input_2 = v; + } + Message::Ignore => {} + Message::ToggleHide => { + self.hidden = !self.hidden; + } + Message::Surface(a) => { + return cosmic::task::message(cosmic::Action::Cosmic( + cosmic::app::Action::Surface(a), + )); + } + Message::Hi => { + dbg!("hi"); + } + Message::Hi2 => { + dbg!("hi 2"); + } + Message::Hi3 => { + dbg!("hi 3"); + } + Message::Tick => { + self.progress = (self.progress + 0.01) % 1.0; + } + } + Task::none() + } + + fn subscription(&self) -> iced::Subscription { + iced::time::every(std::time::Duration::from_millis(64)).map(|_| Message::Tick) } /// Creates a view after each update. - fn view(&self) -> Element { + fn view(&self) -> Element<'_, Self::Message> { let page_content = self .nav_model .active_data::() - .map(String::as_str) - .unwrap_or("No page selected"); + .map_or("No page selected", String::as_str); - let text = cosmic::widget::text(page_content); - - let centered = cosmic::widget::container(text) - .width(iced::Length::Fill) - .height(iced::Length::Shrink) - .align_x(iced::alignment::Horizontal::Center) - .align_y(iced::alignment::Vertical::Center); + let centered = widget::container( + widget::column::with_capacity(14) + .push(widget::text::body(page_content)) + .push( + widget::text_input::text_input("", &self.input_1) + .on_input(Message::Input1) + .on_clear(Message::Ignore), + ) + .push( + widget::text_input::secure_input( + "", + &self.input_1, + Some(Message::ToggleHide), + self.hidden, + ) + .on_input(Message::Input1), + ) + .push(widget::text_input::text_input("", &self.input_2).on_input(Message::Input2)) + .push( + widget::text_input::search_input("", &self.input_2) + .on_input(Message::Input2) + .on_clear(Message::Ignore), + ) + .push(widget::progress_bar::circular::Circular::new().size(50.0)) + .push(widget::progress_bar::circular::Circular::new().size(20.0)) + .push( + widget::progress_bar::linear::Linear::new() + .girth(10.0) + .width(Length::Fill), + ) + .push( + widget::progress_bar::circular::Circular::new() + .bar_height(10.0) + .size(50.0) + .progress(self.progress), + ) + .push( + widget::progress_bar::linear::Linear::new() + .girth(10.0) + .progress(self.progress) + .width(Length::Fill), + ) + .push( + widget::progress_bar::circular::Circular::new() + .size(50.0) + .progress(0.0), + ) + .push( + widget::progress_bar::linear::Linear::new() + .girth(10.0) + .progress(0.0) + .width(Length::Fill), + ) + .push( + widget::progress_bar::circular::Circular::new() + .size(50.0) + .progress(1.0), + ) + .push( + widget::progress_bar::linear::Linear::new() + .girth(10.0) + .progress(1.0) + .width(Length::Fill), + ) + .spacing(cosmic::theme::spacing().space_s) + .width(Length::Fill) + .height(Length::Shrink) + .align_x(Alignment::Center), + ) + .width(Length::Fill) + .height(Length::Shrink) + .align_x(Alignment::Center) + .align_y(Alignment::Center); Element::from(centered) } + + fn header_start(&self) -> Vec> { + vec![cosmic::widget::responsive_menu_bar().into_element( + self.core(), + &self.keybinds, + MENU_ID.clone(), + Message::Surface, + vec![ + ( + "hi 1".into(), + vec![ + menu::Item::Button("hi 12", None, Action::Hi), + menu::Item::Button("hi 13", None, Action::Hi2), + ], + ), + ( + "hi 2".into(), + vec![ + menu::Item::Button("hi 21", None, Action::Hi), + menu::Item::Button("hi 22", None, Action::Hi2), + menu::Item::Folder( + "nest 3 2 >".into(), + vec![ + menu::Item::Button("21", None, Action::Hi), + menu::Item::Button("242", None, Action::Hi2), + menu::Item::Button("2443", None, Action::Hi3), + menu::Item::Folder( + "nest 4 2 >".into(), + vec![ + menu::Item::Button("243", None, Action::Hi2), + menu::Item::Button("2444", None, Action::Hi), + ], + ), + ], + ), + ], + ), + ( + "hi 3".into(), + vec![ + menu::Item::Button("hi 31", None, Action::Hi), + menu::Item::Button("hi 332", None, Action::Hi2), + menu::Item::Button("hi 3333", None, Action::Hi3), + menu::Item::Button("hi 33334", None, Action::Hi3), + menu::Item::Button("hi 333335", None, Action::Hi3), + menu::Item::Button("hi 3333336", None, Action::Hi3), + ], + ), + ( + "hiiiiiiiiiiiiiiiiiii 4".into(), + vec![ + menu::Item::Button("hi 4", None, Action::Hi), + menu::Item::Button("hi 44", None, Action::Hi2), + menu::Item::Button("hi 444", None, Action::Hi3), + menu::Item::Folder( + "nest 4 >".into(), + vec![ + menu::Item::Button("hi 41", None, Action::Hi), + menu::Item::Button("hi 442", None, Action::Hi2), + menu::Item::Folder( + "nest 3 4 >".into(), + vec![ + menu::Item::Button("hi 443", None, Action::Hi2), + menu::Item::Button("hi 4444", None, Action::Hi), + menu::Item::Button("hi 44444", None, Action::Hi3), + menu::Item::Button("hi 444445", None, Action::Hi3), + menu::Item::Button("hi 4444446", None, Action::Hi3), + menu::Item::Button("hi 44444447", None, Action::Hi3), + ], + ), + ], + ), + ], + ), + ], + )] + } } impl App @@ -146,10 +364,14 @@ where .unwrap_or("Unknown Page") } - fn update_title(&mut self) -> Command { + fn update_title(&mut self) -> cosmic::app::Task { let header_title = self.active_page_title().to_owned(); let window_title = format!("{header_title} — COSMIC AppDemo"); self.set_header_title(header_title); - self.set_window_title(window_title) + if let Some(id) = self.core.main_window_id() { + self.set_window_title(window_title, id) + } else { + Task::none() + } } } diff --git a/examples/calendar/Cargo.toml b/examples/calendar/Cargo.toml new file mode 100644 index 00000000..b7286825 --- /dev/null +++ b/examples/calendar/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "calendar" +version = "1.0.0" +edition = "2024" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +jiff = "0.2" + +[dependencies.libcosmic] +path = "../../" +features = ["debug", "winit", "tokio", "xdg-portal", "wgpu"] diff --git a/examples/calendar/src/main.rs b/examples/calendar/src/main.rs new file mode 100644 index 00000000..494087d1 --- /dev/null +++ b/examples/calendar/src/main.rs @@ -0,0 +1,117 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Calendar widget example + +use cosmic::app::{Core, Settings, Task}; +use cosmic::widget::calendar::CalendarModel; +use cosmic::{ApplicationExt, Element, executor, iced}; +use jiff::civil::{Date, Weekday}; + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + cosmic::app::run::(Settings::default(), ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + DateSelected(Date), + PrevMonth, + NextMonth, +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + calendar_model: CalendarModel, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.AppDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Task) { + let mut app = App { + core, + calendar_model: CalendarModel::now(), + }; + + let command = app.update_title(); + + (app, command) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::DateSelected(date) => { + self.calendar_model.selected = date; + } + Message::PrevMonth => { + self.calendar_model.show_prev_month(); + } + Message::NextMonth => { + self.calendar_model.show_next_month(); + } + } + + println!("Date selected: {:?}", &self.calendar_model.selected); + + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element<'_, Self::Message> { + let calendar = cosmic::widget::calendar( + &self.calendar_model, + |date| Message::DateSelected(date), + || Message::PrevMonth, + || Message::NextMonth, + Weekday::Sunday, + ); + + let centered = cosmic::widget::container(calendar) + .width(iced::Length::Fill) + .height(iced::Length::Shrink) + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); + + Element::from(centered) + } +} + +impl App +where + Self: cosmic::Application, +{ + fn update_title(&mut self) -> cosmic::app::Task { + self.set_header_title(String::from("Calendar Demo")); + self.set_window_title( + String::from("Calendar Demo"), + self.core.main_window_id().unwrap(), + ) + } +} diff --git a/examples/config/Cargo.toml b/examples/config/Cargo.toml index 98b49b0f..4f20144c 100644 --- a/examples/config/Cargo.toml +++ b/examples/config/Cargo.toml @@ -7,4 +7,4 @@ publish = false [dependencies] cosmic-config = { path = "../../cosmic-config" } -ron = "0.8.0" +ron = "0.9.0" diff --git a/examples/config/src/main.rs b/examples/config/src/main.rs index f606e15c..f6fb5c0d 100644 --- a/examples/config/src/main.rs +++ b/examples/config/src/main.rs @@ -4,7 +4,7 @@ use cosmic_config::{Config, ConfigGet, ConfigSet}; fn test_config(config: Config) { - let watcher = config + let _watcher = config .watch(|config, keys| { println!("Changed: {:?}", keys); for key in keys.iter() { diff --git a/examples/context-menu/Cargo.toml b/examples/context-menu/Cargo.toml new file mode 100644 index 00000000..39c550f4 --- /dev/null +++ b/examples/context-menu/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "context-menu" +version = "0.1.0" +edition = "2021" + +[dependencies] +tracing = "0.1.44" +tracing-subscriber = "0.3.22" +tracing-log = "0.2.0" + +[dependencies.libcosmic] +path = "../../" +features = [ + "debug", + "winit", + "wgpu", + "tokio", + "xdg-portal", + "surface-message", + "wayland", +] diff --git a/examples/context-menu/src/main.rs b/examples/context-menu/src/main.rs new file mode 100644 index 00000000..e5ca5878 --- /dev/null +++ b/examples/context-menu/src/main.rs @@ -0,0 +1,159 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Application API example + +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::Size; +use cosmic::widget::menu; +use cosmic::{executor, iced, ApplicationExt, Element}; +use std::collections::HashMap; + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + let settings = Settings::default() + .size(Size::new(1024., 768.)); + + cosmic::app::run::(settings, ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + Clicked, + WindowClose, + Surface(cosmic::surface::Action), + ToggleHideContent, + WindowNew, +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + button_label: String, + hide_content: bool, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.ContextMenuDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Task) { + let mut app = App { + core, + button_label: String::from("Right click me"), + hide_content: false, + }; + + app.set_header_title("COSMIC Context Menu Demo".into()); + let command = if let Some(win_id) = app.core.main_window_id() { + app.set_window_title("COSMIC Context Menu Demo".into(), win_id) + } else { + Task::none() + }; + + (app, command) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::Clicked => { + self.button_label = format!("Clicked {message:?}"); + } + Message::Surface(action) => { + return cosmic::task::message(cosmic::Action::Cosmic( + cosmic::app::Action::Surface(action), + )); + } + Message::WindowClose => {} + Message::ToggleHideContent => {} + Message::WindowNew => {} + } + + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element<'_, Self::Message> { + let widget = cosmic::widget::context_menu( + cosmic::widget::button::text(self.button_label.to_string()).on_press(Message::Clicked), + self.context_menu(), + ) + .on_surface_action(Message::Surface); + + let centered = cosmic::widget::container(widget) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); + + Element::from(centered) + } +} + +impl App { + fn context_menu(&self) -> Option>> { + Some(menu::items( + &HashMap::new(), + vec![ + menu::Item::Button("New window", None, ContextMenuAction::WindowNew), + menu::Item::Divider, + menu::Item::Folder( + "View", + vec![menu::Item::CheckBox( + "Hide content", + None, + self.hide_content, + ContextMenuAction::ToggleHideContent, + )], + ), + menu::Item::Divider, + menu::Item::Button("Quit", None, ContextMenuAction::WindowClose), + ], + )) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContextMenuAction { + WindowClose, + ToggleHideContent, + WindowNew, +} + +impl menu::Action for ContextMenuAction { + type Message = Message; + fn message(&self) -> Self::Message { + match self { + ContextMenuAction::WindowClose => Message::WindowClose, + ContextMenuAction::ToggleHideContent => Message::ToggleHideContent, + ContextMenuAction::WindowNew => Message::WindowNew, + } + } +} diff --git a/examples/cosmic/Cargo.toml b/examples/cosmic/Cargo.toml index a4c3bf3e..8c2a3126 100644 --- a/examples/cosmic/Cargo.toml +++ b/examples/cosmic/Cargo.toml @@ -7,14 +7,23 @@ publish = false [dependencies] apply = "0.3.0" -fraction = "0.13.0" -libcosmic = { path = "../..", features = ["debug", "winit", "tokio", "single-instance"] } -once_cell = "1.18" -slotmap = "1.0.6" +fraction = "0.15.3" +libcosmic = { path = "../..", features = [ + "debug", + "winit", + "tokio", + "single-instance", + "dbus-config", + "a11y", + "wgpu", + "xdg-portal", +] } +once_cell = "1.21" +slotmap = "1.1.1" env_logger = "0.10" -log = "0.4.17" +log = "0.4.29" [dependencies.cosmic-time] git = "https://github.com/pop-os/cosmic-time" default-features = false -features = ["libcosmic", "once_cell"] \ No newline at end of file +features = ["once_cell"] diff --git a/examples/cosmic/src/main.rs b/examples/cosmic/src/main.rs index f180b100..ed470ebc 100644 --- a/examples/cosmic/src/main.rs +++ b/examples/cosmic/src/main.rs @@ -14,7 +14,14 @@ pub fn main() -> cosmic::iced::Result { env_logger::init_from_env(env); cosmic::icon_theme::set_default("Pop"); - let mut settings = Settings::default(); - settings.window.min_size = Some((600, 300)); + #[allow(clippy::field_reassign_with_default)] + let settings = Settings { + default_font: cosmic::font::default(), + window: cosmic::iced::window::Settings { + min_size: Some(cosmic::iced::Size::new(600., 300.)), + ..cosmic::iced::window::Settings::default() + }, + ..Settings::default() + }; Window::run(settings) } diff --git a/examples/cosmic/src/window.rs b/examples/cosmic/src/window.rs index 023dadf7..9fce8767 100644 --- a/examples/cosmic/src/window.rs +++ b/examples/cosmic/src/window.rs @@ -6,17 +6,18 @@ use cosmic::{ ThemeBuilder, }, font::load_fonts, - iced::{self, Application, Command, Length, Subscription}, + iced::{self, Application, Length, Subscription, Task}, iced::{ subscription, widget::{self, column, container, horizontal_space, row, text}, window::{self, close, drag, minimize, toggle_maximize}, }, + iced_futures::event::listen_raw, keyboard_nav, prelude::*, theme::{self, Theme}, widget::{ - button, header_bar, icon, list, nav_bar, nav_bar_toggle, scrollable, segmented_button, + button, container, header_bar, icon, nav_bar, nav_bar_toggle, scrollable, segmented_button, settings, warning, }, Element, @@ -230,7 +231,7 @@ impl Window { } fn page_title(&self, page: Page) -> Element { - row!(text(page.title()).size(28), horizontal_space(Length::Fill),).into() + row!(text(page.title()).size(28), horizontal_space(),).into() } fn is_condensed(&self) -> bool { @@ -252,10 +253,7 @@ impl Window { .label(page.title()) .padding(0) .on_press(Message::from(page)), - row!( - text(sub_page.title()).size(28), - horizontal_space(Length::Fill), - ), + row!(text(sub_page.title()).size(28), horizontal_space(),), ) .spacing(10) .into() @@ -271,7 +269,7 @@ impl Window { sub_page: impl SubPage, ) -> Element { iced::widget::Button::new( - list::container( + container( settings::item_row(vec![ icon::from_name(sub_page.icon_name()).size(20).icon().into(), column!( @@ -280,13 +278,16 @@ impl Window { ) .spacing(2) .into(), - horizontal_space(iced::Length::Fill).into(), + horizontal_space().into(), icon::from_name("go-next-symbolic").size(20).icon().into(), ]) .spacing(16), ) - .padding([20, 24]), + .padding([20, 24]) + .class(theme::Container::List) + .width(Length::Fill), ) + .width(Length::Fill) .padding(0) .style(theme::iced::Button::Transparent) .on_press(Message::from(sub_page.into_page())) @@ -322,7 +323,7 @@ impl Application for Window { type Message = Message; type Theme = Theme; - fn new(_flags: ()) -> (Self, Command) { + fn new(_flags: ()) -> (Self, Task) { let mut window = Window::default() .nav_bar_toggled(true) .show_maximize(true) @@ -358,11 +359,8 @@ impl Application for Window { } fn subscription(&self) -> Subscription { - let window_break = subscription::events_with(|event, _| match event { - cosmic::iced::Event::Window( - _window_id, - window::Event::Resized { width, height: _ }, - ) => { + let window_break = listen_raw(|event, _| match event { + cosmic::iced::Event::Window(window::Event::Resized { width, height: _ }) => { let old_width = WINDOW_WIDTH.load(Ordering::Relaxed); if old_width == 0 || old_width < BREAK_POINT && width > BREAK_POINT @@ -387,8 +385,8 @@ impl Application for Window { ]) } - fn update(&mut self, message: Message) -> iced::Command { - let mut ret = Command::none(); + fn update(&mut self, message: Message) -> iced::Task { + let mut ret = Task::none(); match message { Message::NavBar(key) => { if let Some(page) = self.nav_id_to_page.get(key).copied() { @@ -435,22 +433,21 @@ impl Application for Window { Message::ToggleNavBarCondensed => { self.nav_bar_toggled_condensed = !self.nav_bar_toggled_condensed } - Message::Drag => return drag(), - Message::Close => return close(), - Message::Minimize => return minimize(true), - Message::Maximize => return toggle_maximize(), + Message::Drag => return drag(self.core.main_window_id().unwrap()), + Message::Close => return close(self.core.main_window_id().unwrap()), + Message::Minimize => return minimize(self.core.main_window_id().unwrap(), true), + Message::Maximize => return toggle_maximize(self.core.main_window_id().unwrap()), Message::InputChanged => {} Message::CondensedViewToggle => {} Message::KeyboardNav(message) => match message { - keyboard_nav::Message::Unfocus => ret = keyboard_nav::unfocus(), keyboard_nav::Message::FocusNext => ret = widget::focus_next(), keyboard_nav::Message::FocusPrevious => ret = widget::focus_previous(), _ => (), }, Message::ToggleWarning => self.toggle_warning(), - Message::FontsLoaded => {} + Message::FontsLoaded => {} // Message::Tick(instant) => self.timeline.borrow_mut().now(instant), Message::Tick(instant) => self.timeline.borrow_mut().now(instant), Message::Tick(instant) => self.timeline.borrow_mut().now(instant), } ret @@ -489,7 +486,7 @@ impl Application for Window { let mut widgets = Vec::with_capacity(2); if nav_bar_toggled { - let mut nav_bar = nav_bar(&self.nav_bar, Message::NavBar); + let mut nav_bar = nav_bar(&self.nav_bar, Message::NavBar).into_container(); if !self.is_condensed() { nav_bar = nav_bar.max_width(300); @@ -563,12 +560,9 @@ impl Application for Window { }; widgets.push( - scrollable(row![ - horizontal_space(Length::Fill), - content.debug(self.debug), - horizontal_space(Length::Fill), - ]) - .into(), + scrollable(container(content.debug(self.debug)).align_x(iced::Alignment::Center)) + .width(Length::Fill) + .into(), ); } @@ -586,7 +580,9 @@ impl Application for Window { header, container(column(vec![ warning, - iced::widget::vertical_space(Length::Fixed(12.0)).into(), + iced::widget::vertical_space() + .width(Length::Fixed(12.0)) + .into(), content, ])) .style(theme::Container::Background) diff --git a/examples/cosmic/src/window/bluetooth.rs b/examples/cosmic/src/window/bluetooth.rs index 44fe7d6c..1b5892f6 100644 --- a/examples/cosmic/src/window/bluetooth.rs +++ b/examples/cosmic/src/window/bluetooth.rs @@ -28,13 +28,14 @@ impl State { column!( list_column().add(settings::item( "Bluetooth", - toggler(None, self.enabled, Message::Enable) + toggler(self.enabled).on_toggle(Message::Enable) )), text("Now visible as \"TODO\", just kidding") ) .spacing(8) .into(), - settings::view_section("Devices") + settings::section() + .title("Devices") .add(settings::item("No devices found", text(""))) .into(), ]) diff --git a/examples/cosmic/src/window/demo.rs b/examples/cosmic/src/window/demo.rs index 5a484f09..0d31fa93 100644 --- a/examples/cosmic/src/window/demo.rs +++ b/examples/cosmic/src/window/demo.rs @@ -3,12 +3,13 @@ use std::{cell::RefCell, rc::Rc}; use apply::Apply; use cosmic::{ cosmic_theme, - iced::widget::{checkbox, column, progress_bar, radio, slider, text, text_input}, - iced::{id, Alignment, Length}, + iced::widget::{checkbox, column, progress_bar, radio, slider, text}, + iced::{Alignment, Length}, + iced_core::id, theme::ThemeType, widget::{ - button, color_picker::ColorPickerUpdate, cosmic_container::container, dropdown, icon, - segmented_button, segmented_selection, settings, spin_button, toggler, view_switcher, + button, color_picker::ColorPickerUpdate, dropdown, icon, layer_container as container, + segmented_button, segmented_control, settings, spin_button, tab_bar, toggler, ColorPickerModel, }, Element, @@ -39,7 +40,7 @@ impl From<&ThemeType> for ThemeVariant { ThemeType::HighContrastDark => ThemeVariant::HighContrastDark, ThemeType::HighContrastLight => ThemeVariant::HighContrastLight, ThemeType::Custom(_) => ThemeVariant::Custom, - ThemeType::System(_) => ThemeVariant::System, + ThemeType::System { .. } => ThemeVariant::System, } } } @@ -88,6 +89,7 @@ pub enum Message { ClearAll, CardsToggled(bool), ColorPickerUpdate(ColorPickerUpdate), + Hidden, } pub enum Output { @@ -108,12 +110,13 @@ pub struct State { pub slider_value: f32, pub spin_button: spin_button::Model, pub toggler_value: bool, - pub view_switcher: segmented_button::SingleSelectModel, + pub tab_bar: segmented_button::SingleSelectModel, pub entry_value: String, pub cards_value: bool, cards: Vec, pub timeline: Rc>, pub color_picker_model: ColorPickerModel, + pub hidden: bool, } impl Default for State { @@ -146,7 +149,7 @@ impl Default for State { .insert(|b| b.text("Option D").data(MultiOption::OptionD)) .insert(|b| b.text("Option E").data(MultiOption::OptionE)) .build(), - view_switcher: segmented_button::Model::builder() + tab_bar: segmented_button::Model::builder() .insert(|b| b.text("Controls").data(DemoView::TabA).activate()) .insert(|b| b.text("Segmented Button").data(DemoView::TabB)) .insert(|b| b.text("Tab C").data(DemoView::TabC)) @@ -161,6 +164,7 @@ impl Default for State { ], timeline: Rc::new(RefCell::new(Default::default())), color_picker_model: ColorPickerModel::new("Hex", "RGB", None, None), + hidden: false, } } } @@ -186,7 +190,7 @@ impl State { Message::ThemeChanged(theme) => return Some(Output::ThemeChanged(theme)), Message::ToggleWarning => return Some(Output::ToggleWarning), Message::TogglerToggled(value) => self.toggler_value = value, - Message::ViewSwitcher(key) => self.view_switcher.activate(key), + Message::ViewSwitcher(key) => self.tab_bar.activate(key), Message::IconTheme(key) => { self.icon_themes.activate(key); if let Some(theme) = self.icon_themes.text(key) { @@ -209,6 +213,9 @@ impl State { Message::ColorPickerUpdate(u) => { _ = self.color_picker_model.update::(u); } + Message::Hidden => { + self.hidden = !self.hidden; + } } None @@ -241,22 +248,23 @@ impl State { ); let choose_icon_theme = - segmented_selection::horizontal(&self.icon_themes).on_activate(Message::IconTheme); + segmented_control::horizontal(&self.icon_themes).on_activate(Message::IconTheme); let timeline = self.timeline.borrow(); settings::view_column(vec![ window.page_title(Page::Demo), - view_switcher::horizontal(&self.view_switcher) + tab_bar::horizontal(&self.tab_bar) .on_activate(Message::ViewSwitcher) .into(), - match self.view_switcher.active_data() { + match self.tab_bar.active_data() { None => panic!("no tab is active"), Some(DemoView::TabA) => settings::view_column(vec![ - settings::view_section("Debug") + settings::section() + .title("Debug") .add(settings::item("Debug theme", choose_theme)) .add(settings::item("Debug icon theme", choose_icon_theme)) .add(settings::item( "Debug layout", - toggler(None, window.debug, Message::Debug), + toggler(window.debug).on_toggle(Message::Debug), )) .add(settings::item( "Scaling Factor", @@ -269,10 +277,11 @@ impl State { .into(), ])) .into(), - settings::view_section("Controls") + settings::section() + .title("Controls") .add(settings::item( "Toggler", - toggler(None, self.toggler_value, Message::TogglerToggled), + toggler(self.toggler_value).on_toggle(Message::TogglerToggled), )) .add(settings::item( "Pick List (TODO)", @@ -292,15 +301,13 @@ impl State { .add(settings::item( "Progress", progress_bar(0.0..=100.0, self.slider_value) - .width(Length::Fixed(250.0)) - .height(Length::Fixed(4.0)), + .length(Length::Fixed(250.0)) + .girth(Length::Fixed(4.0)), )) - .add(settings::item_row(vec![checkbox( - "Checkbox", - self.checkbox_value, - Message::CheckboxToggled, - ) - .into()])) + .add(settings::item_row(vec![checkbox(self.checkbox_value) + .label("Checkbox") + .on_toggle(Message::CheckboxToggled) + .into()])) .add(settings::item( format!( "Spin Button (Range {}:{})", @@ -313,46 +320,55 @@ impl State { .padding(0) .into(), Some(DemoView::TabB) => settings::view_column(vec![ - text("Selection").font(cosmic::font::FONT_SEMIBOLD).into(), + text("Selection").font(cosmic::font::semibold()).into(), text("Horizontal").into(), - segmented_selection::horizontal(&self.selection) + segmented_control::horizontal(&self.selection) .on_activate(Message::Selection) .into(), text("Horizontal With Spacing").into(), - segmented_selection::horizontal(&self.selection) + segmented_control::horizontal(&self.selection) .spacing(8) .on_activate(Message::Selection) .into(), + text("Disabled Horizontal With Spacing").into(), + segmented_control::horizontal(&self.selection) + .spacing(8) + .into(), text("Horizontal Multi-Select").into(), - segmented_selection::horizontal(&self.multi_selection) + segmented_control::horizontal(&self.multi_selection) .spacing(8) .on_activate(Message::MultiSelection) .into(), + text("Disabled Horizontal Multi-Select").into(), + segmented_control::horizontal(&self.multi_selection) + .spacing(8) + .into(), text("Vertical").into(), - segmented_selection::vertical(&self.selection) + segmented_control::vertical(&self.selection) .on_activate(Message::Selection) .into(), + text("Disabled Vertical").into(), + segmented_control::vertical(&self.selection).into(), text("Vertical Multi-Select Shrunk").into(), - segmented_selection::vertical(&self.multi_selection) + segmented_control::vertical(&self.multi_selection) .width(Length::Shrink) .on_activate(Message::MultiSelection) .apply(container) - .center_x() - .width(Length::Fill) + .center_x(Length::Fill) .into(), text("Vertical With Spacing").into(), cosmic::iced::widget::row(vec![ - segmented_selection::vertical(&self.selection) + segmented_control::vertical(&self.selection) .spacing(8) .on_activate(Message::Selection) .width(Length::FillPortion(1)) .into(), - segmented_selection::vertical(&self.selection) + segmented_control::vertical(&self.selection) .spacing(8) .on_activate(Message::Selection) .width(Length::FillPortion(1)) .into(), - segmented_selection::vertical(&self.selection) + segmented_control::vertical(&self.selection) .spacing(8) .on_activate(Message::Selection) .width(Length::FillPortion(1)) @@ -361,43 +377,41 @@ impl State { .spacing(12) .width(Length::Fill) .into(), - text("View Switcher") - .font(cosmic::font::FONT_SEMIBOLD) - .into(), + text("View Switcher").font(cosmic::font::semibold()).into(), text("Horizontal").into(), - view_switcher::horizontal(&self.selection) + tab_bar::horizontal(&self.selection) .on_activate(Message::Selection) .into(), text("Horizontal Multi-Select").into(), - view_switcher::horizontal(&self.multi_selection) + tab_bar::horizontal(&self.multi_selection) .on_activate(Message::MultiSelection) .into(), text("Horizontal With Spacing").into(), - view_switcher::horizontal(&self.selection) + tab_bar::horizontal(&self.selection) .spacing(8) .on_activate(Message::Selection) .into(), text("Vertical").into(), - view_switcher::vertical(&self.selection) + tab_bar::vertical(&self.selection) .on_activate(Message::Selection) .into(), text("Vertical Multi-Select").into(), - view_switcher::vertical(&self.multi_selection) + tab_bar::vertical(&self.multi_selection) .on_activate(Message::MultiSelection) .into(), text("Vertical With Spacing").into(), cosmic::iced::widget::row(vec![ - view_switcher::vertical(&self.selection) + tab_bar::vertical(&self.selection) .spacing(8) .on_activate(Message::Selection) .width(Length::FillPortion(1)) .into(), - view_switcher::vertical(&self.selection) + tab_bar::vertical(&self.selection) .spacing(8) .on_activate(Message::Selection) .width(Length::FillPortion(1)) .into(), - view_switcher::vertical(&self.selection) + tab_bar::vertical(&self.selection) .spacing(8) .on_activate(Message::Selection) .width(Length::FillPortion(1)) @@ -409,13 +423,12 @@ impl State { ]) .padding(0) .into(), - Some(DemoView::TabC) => { - settings::view_column(vec![settings::view_section("Tab C") - .add(text("Nothing here yet").width(Length::Fill)) - .into()]) - .padding(0) - .into() - } + Some(DemoView::TabC) => settings::view_column(vec![settings::section() + .title("Tab C") + .add(text("Nothing here yet").width(Length::Fill)) + .into()]) + .padding(0) + .into(), }, container(text("Background container with some text").size(24)) .layer(cosmic_theme::Layer::Background) @@ -432,7 +445,7 @@ impl State { .icon(), icon::from_name("microphone-sensitivity-high-symbolic-test") .size(24) - .fallback(false) + .fallback(None) .icon(), ]) .layer(cosmic_theme::Layer::Primary) @@ -467,56 +480,24 @@ impl State { )) .layer(cosmic::cosmic_theme::Layer::Secondary) .padding(16) - .style(cosmic::theme::Container::Background) + .class(cosmic::theme::Container::Background) .into(), - text_input( + cosmic::widget::text_input::secure_input( "Type to search apps or type “?” for more options...", &self.entry_value, + Some(Message::Hidden), + self.hidden, ) .on_input(Message::InputChanged) - // .on_submit(Message::Activate(None)) .size(20) .id(INPUT_ID.clone()) .into(), - cosmic::widget::text_input("test", &self.entry_value) - .on_clear(Message::InputChanged("".to_string())) - .width(Length::Fill) - .on_input(Message::InputChanged) - .into(), - cosmic::widget::text_input("test", &self.entry_value) - .width(Length::Fixed(600.0)) - .padding(32) - .on_input(Message::InputChanged) - .into(), - cosmic::widget::search_input("test", &self.entry_value) - .on_clear(Message::InputChanged("".to_string())) - .width(Length::Fill) - .on_input(Message::InputChanged) - .into(), - cosmic::widget::text_input("test", &self.entry_value) - .width(Length::Fixed(600.0)) - .on_input(Message::InputChanged) - .into(), - cosmic::widget::search_input("test", &self.entry_value) - .on_clear(Message::InputChanged("".to_string())) - .width(Length::Fixed(100.0)) - .on_input(Message::InputChanged) - .into(), - cosmic::widget::search_input("test", &self.entry_value) - .on_clear(Message::InputChanged("".to_string())) - .padding([24, 48]) - .width(Length::Fixed(400.0)) - .on_input(Message::InputChanged) - .into(), - cosmic::widget::search_input("test", &self.entry_value) - .on_clear(Message::InputChanged("".to_string())) - .width(Length::Fixed(400.0)) - .on_input(Message::InputChanged) - .into(), - cosmic::widget::search_input("test", &self.entry_value) - .on_clear(Message::InputChanged("".to_string())) - .width(Length::Fixed(800.0)) + cosmic::widget::text_input("", &self.entry_value) + .label("Test Input") + .helper_text("test helper text") .on_input(Message::InputChanged) + .size(20) + .id(INPUT_ID.clone()) .into(), self.color_picker_model .picker_button(Message::ColorPickerUpdate, None) diff --git a/examples/cosmic/src/window/desktop.rs b/examples/cosmic/src/window/desktop.rs index 3b3858da..46a4e5b8 100644 --- a/examples/cosmic/src/window/desktop.rs +++ b/examples/cosmic/src/window/desktop.rs @@ -135,6 +135,7 @@ impl State { .spacing(16) .into(), ]) + .width(Length::Fill) .into(), Some(DesktopPage::DesktopOptions) => self.view_desktop_options(window), Some(DesktopPage::Wallpaper) => self.view_desktop_wallpaper(window), @@ -146,7 +147,8 @@ impl State { fn view_desktop_options<'a>(&'a self, window: &'a Window) -> Element<'a, Message> { settings::view_column(vec![ window.parent_page_button(DesktopPage::DesktopOptions), - settings::view_section("Super Key Action") + settings::section() + .title("Super Key Action") .add(settings::item("Launcher", horizontal_space(Length::Fill))) .add(settings::item("Workspaces", horizontal_space(Length::Fill))) .add(settings::item( @@ -154,38 +156,34 @@ impl State { horizontal_space(Length::Fill), )) .into(), - settings::view_section("Hot Corner") + settings::section() + .title("Hot Corner") .add(settings::item( "Enable top-left hot corner for Workspaces", - toggler(None, self.top_left_hot_corner, Message::TopLeftHotCorner), + toggler(self.top_left_hot_corner).on_toggle(Message::TopLeftHotCorner), )) .into(), - settings::view_section("Top Panel") + settings::section() + .title("Top Panel") .add(settings::item( "Show Workspaces Button", - toggler( - None, - self.show_workspaces_button, - Message::ShowWorkspacesButton, - ), + toggler(self.show_workspaces_button).on_toggle(Message::ShowWorkspacesButton), )) .add(settings::item( "Show Applications Button", - toggler( - None, - self.show_applications_button, - Message::ShowApplicationsButton, - ), + toggler(self.show_applications_button) + .on_toggle(Message::ShowApplicationsButton), )) .into(), - settings::view_section("Window Controls") + settings::section() + .title("Window Controls") .add(settings::item( "Show Minimize Button", - toggler(None, self.show_minimize_button, Message::ShowMinimizeButton), + toggler(self.show_minimize_button).on_toggle(Message::ShowMinimizeButton), )) .add(settings::item( "Show Maximize Button", - toggler(None, self.show_maximize_button, Message::ShowMaximizeButton), + toggler(self.show_maximize_button).on_toggle(Message::ShowMaximizeButton), )) .into(), ]) @@ -244,12 +242,12 @@ impl State { list_column() .add(settings::item( "Same background on all displays", - toggler(None, self.same_background, Message::SameBackground), + toggler(self.same_background).on_toggle(Message::SameBackground), )) .add(settings::item("Background fit", text("TODO"))) .add(settings::item( "Slideshow", - toggler(None, self.slideshow, Message::Slideshow), + toggler(self.slideshow).on_toggle(Message::Slideshow), )) .into(), column(image_column).spacing(16).into(), @@ -260,7 +258,8 @@ impl State { fn view_desktop_workspaces<'a>(&'a self, window: &'a Window) -> Element<'a, Message> { settings::view_column(vec![ window.parent_page_button(DesktopPage::Wallpaper), - settings::view_section("Workspace Behavior") + settings::section() + .title("Workspace Behavior") .add(settings::item( "Dynamic workspaces", horizontal_space(Length::Fill), @@ -270,7 +269,8 @@ impl State { horizontal_space(Length::Fill), )) .into(), - settings::view_section("Multi-monitor Behavior") + settings::section() + .title("Multi-monitor Behavior") .add(settings::item( "Workspaces Span Displays", horizontal_space(Length::Fill), diff --git a/examples/cosmic/src/window/editor.rs b/examples/cosmic/src/window/editor.rs index 764a4cfe..a8a7f5a6 100644 --- a/examples/cosmic/src/window/editor.rs +++ b/examples/cosmic/src/window/editor.rs @@ -1,6 +1,6 @@ use cosmic::iced::widget::{horizontal_space, row}; use cosmic::iced::{Alignment, Length}; -use cosmic::widget::{button, icon, segmented_button, view_switcher}; +use cosmic::widget::{button, icon, segmented_button, tab_bar}; use cosmic::{Apply, Element}; use slotmap::Key; @@ -60,7 +60,7 @@ impl State { } pub(super) fn view<'a>(&'a self, _window: &'a super::Window) -> Element<'a, Message> { - let tabs = view_switcher::horizontal(&self.pages) + let tabs = tab_bar::horizontal(&self.pages) .show_close_icon_on_hover(true) .on_activate(Message::Activate) .on_close(Message::Close) diff --git a/examples/cosmic/src/window/system_and_accounts.rs b/examples/cosmic/src/window/system_and_accounts.rs index e42e643c..ed1bd004 100644 --- a/examples/cosmic/src/window/system_and_accounts.rs +++ b/examples/cosmic/src/window/system_and_accounts.rs @@ -69,14 +69,16 @@ impl State { list_column() .add(settings::item("Device name", text("TODO"))) .into(), - settings::view_section("Hardware") + settings::section() + .title("Hardware") .add(settings::item("Hardware model", text("TODO"))) .add(settings::item("Memory", text("TODO"))) .add(settings::item("Processor", text("TODO"))) .add(settings::item("Graphics", text("TODO"))) .add(settings::item("Disk Capacity", text("TODO"))) .into(), - settings::view_section("Operating System") + settings::section() + .title("Operating System") .add(settings::item("Operating system", text("TODO"))) .add(settings::item( "Operating system architecture", @@ -85,7 +87,8 @@ impl State { .add(settings::item("Desktop environment", text("TODO"))) .add(settings::item("Windowing system", text("TODO"))) .into(), - settings::view_section("Related settings") + settings::section() + .title("Related settings") .add(settings::item("Get support", text("TODO"))) .into(), ]) diff --git a/examples/design-demo b/examples/design-demo deleted file mode 160000 index 493e17a0..00000000 --- a/examples/design-demo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 493e17a0105c7523fb7ff5fd7221ec586ac9010f diff --git a/examples/image-button/Cargo.toml b/examples/image-button/Cargo.toml index b87ac435..c219a53b 100644 --- a/examples/image-button/Cargo.toml +++ b/examples/image-button/Cargo.toml @@ -1,13 +1,12 @@ [package] -name = "cosmic-image-button" +name = "image-button" version = "0.1.0" edition = "2021" [dependencies] -tracing = "0.1.37" -tracing-subscriber = "0.3.17" +tracing = "0.1.44" +tracing-subscriber = "0.3.22" [dependencies.libcosmic] path = "../../" -default-features = false -features = ["debug", "wayland", "tokio"] +features = ["debug", "winit", "wgpu", "tokio"] diff --git a/examples/image-button/src/main.rs b/examples/image-button/src/main.rs index bfa51ba2..c68c7070 100644 --- a/examples/image-button/src/main.rs +++ b/examples/image-button/src/main.rs @@ -3,7 +3,7 @@ //! Application API example -use cosmic::app::{Command, Core, Settings}; +use cosmic::app::{Core, Settings, Task}; use cosmic::{executor, iced, ApplicationExt, Element}; /// Runs application with these settings @@ -50,8 +50,8 @@ impl cosmic::Application for App { &mut self.core } - /// Creates the application, and optionally emits command on initialize. - fn init(core: Core, _input: Self::Flags) -> (Self, Command) { + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Task) { let mut app = App { core, selected: 0, @@ -67,7 +67,7 @@ impl cosmic::Application for App { } /// Handle application events here. - fn update(&mut self, message: Self::Message) -> Command { + fn update(&mut self, message: Self::Message) -> Task { match message { Message::Clicked(id) => self.selected = id, Message::Remove(id) => { @@ -75,12 +75,12 @@ impl cosmic::Application for App { } } - Command::none() + Task::none() } /// Creates a view after each update. - fn view(&self) -> Element { - let mut content = cosmic::widget::column().spacing(12); + fn view(&self) -> Element<'_, Self::Message> { + let mut content = cosmic::widget::column::with_capacity(self.images.len()).spacing(12); for (id, image) in self.images.iter().enumerate() { content = content.push( @@ -95,8 +95,8 @@ impl cosmic::Application for App { let centered = cosmic::widget::container(content) .width(iced::Length::Fill) .height(iced::Length::Shrink) - .align_x(iced::alignment::Horizontal::Center) - .align_y(iced::alignment::Vertical::Center); + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); Element::from(centered) } @@ -106,8 +106,11 @@ impl App where Self: cosmic::Application, { - fn update_title(&mut self) -> Command { + fn update_title(&mut self) -> Task { self.set_header_title(String::from("Image Button Demo")); - self.set_window_title(String::from("Image Button Demo")) + self.set_window_title( + String::from("Image Button Demo"), + self.core.main_window_id().unwrap(), + ) } } diff --git a/examples/menu/Cargo.toml b/examples/menu/Cargo.toml new file mode 100644 index 00000000..430b26ea --- /dev/null +++ b/examples/menu/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "menu" +version = "0.1.0" +edition = "2021" + +[dependencies] +tracing = "0.1.44" +tracing-subscriber = "0.3.22" +tracing-log = "0.2.0" + +[dependencies.libcosmic] +path = "../../" +features = ["debug", "winit", "tokio", "xdg-portal", "wgpu"] diff --git a/examples/menu/src/main.rs b/examples/menu/src/main.rs new file mode 100644 index 00000000..da0c3231 --- /dev/null +++ b/examples/menu/src/main.rs @@ -0,0 +1,212 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Application API example + +use std::collections::HashMap; +use std::{env, process}; + +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::alignment::{Horizontal, Vertical}; +use cosmic::iced::keyboard::Key; +use cosmic::iced::window; +use cosmic::iced::{Length, Size}; +use cosmic::widget::menu::action::MenuAction; +use cosmic::widget::menu::key_bind::KeyBind; +use cosmic::widget::menu::key_bind::Modifier; +use cosmic::widget::menu::{self, ItemHeight, ItemWidth}; +use cosmic::widget::RcElementWrapper; +use cosmic::{executor, Element}; + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + let settings = Settings::default() + .antialiasing(true) + .client_decorations(true) + .debug(false) + .default_icon_theme("Pop") + .default_text_size(16.0) + .scale_factor(1.0) + .size(Size::new(1024., 768.)); + + cosmic::app::run::(settings, ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + WindowClose, + WindowNew, + ToggleHideContent, +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + config: Config, + key_binds: HashMap, +} + +pub struct Config { + hide_content: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Action { + WindowClose, + ToggleHideContent, + WindowNew, +} + +impl MenuAction for Action { + type Message = Message; + fn message(&self) -> Self::Message { + match self { + Action::WindowClose => Message::WindowClose, + Action::ToggleHideContent => Message::ToggleHideContent, + Action::WindowNew => Message::WindowNew, + } + } +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.AppDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Task) { + let app = App { + core, + config: Config { + hide_content: false, + }, + key_binds: key_binds(), + }; + + (app, Task::none()) + } + + fn header_start(&self) -> Vec> { + vec![menu_bar(&self.config, &self.key_binds)] + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::WindowClose => { + return window::close(self.core.main_window_id().unwrap()); + } + Message::WindowNew => match env::current_exe() { + Ok(exe) => match process::Command::new(&exe).spawn() { + Ok(_child) => {} + Err(err) => { + eprintln!("failed to execute {:?}: {}", exe, err); + } + }, + Err(err) => { + eprintln!("failed to get current executable path: {}", err); + } + }, + Message::ToggleHideContent => self.config.hide_content = !self.config.hide_content, + } + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element<'_, Self::Message> { + let text = if self.config.hide_content { + cosmic::widget::text("") + } else { + cosmic::widget::text("Menu Example") + }; + + let centered = cosmic::widget::container(text) + .width(Length::Fill) + .height(Length::Shrink) + .align_x(Horizontal::Center) + .align_y(Vertical::Center); + + Element::from(centered) + } +} + +pub fn menu_bar<'a>(config: &Config, key_binds: &HashMap) -> Element<'a, Message> { + menu::bar(vec![menu::Tree::with_children( + RcElementWrapper::new(Element::from(menu::root("File"))), + menu::items( + key_binds, + vec![ + menu::Item::Button( + "New window", + Some(cosmic::widget::icon::from_name("screenshot-window-symbolic").into()), + Action::WindowNew, + ), + menu::Item::Divider, + menu::Item::Folder( + "View", + vec![menu::Item::CheckBox( + "Hide content", + Some(cosmic::widget::icon::from_name("view-conceal-symbolic").into()), + config.hide_content, + Action::ToggleHideContent, + )], + ), + menu::Item::Divider, + menu::Item::Button( + "Quit", + Some(cosmic::widget::icon::from_name("window-close-symbolic").into()), + Action::WindowClose, + ), + ], + ), + )]) + .item_height(ItemHeight::Dynamic(40)) + .item_width(ItemWidth::Uniform(240)) + .spacing(4.0) + .into() +} + +pub fn key_binds() -> HashMap { + let mut key_binds = HashMap::new(); + + macro_rules! bind { + ([$($modifier:ident),* $(,)?], $key:expr, $action:ident) => {{ + key_binds.insert( + KeyBind { + modifiers: vec![$(Modifier::$modifier),*], + key: $key, + }, + Action::$action, + ); + }}; + } + + bind!([Ctrl], Key::Character("w".into()), WindowClose); + bind!([Ctrl, Shift], Key::Character("n".into()), WindowNew); + + key_binds +} diff --git a/examples/multi-window/Cargo.toml b/examples/multi-window/Cargo.toml new file mode 100644 index 00000000..0b5440f8 --- /dev/null +++ b/examples/multi-window/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "multi-window" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +libcosmic = { path = "../..", features = ["debug", "winit", "tokio", "single-instance", "wgpu", "wayland"] } diff --git a/examples/multi-window/src/main.rs b/examples/multi-window/src/main.rs new file mode 100644 index 00000000..0a5fc03f --- /dev/null +++ b/examples/multi-window/src/main.rs @@ -0,0 +1,9 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +mod window; +pub use window::*; + +pub fn main() -> cosmic::iced::Result { + cosmic::app::run::(Default::default(), ()) +} diff --git a/examples/multi-window/src/window.rs b/examples/multi-window/src/window.rs new file mode 100644 index 00000000..754a0d86 --- /dev/null +++ b/examples/multi-window/src/window.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; + +use cosmic::{ + app::Core, + iced::core::{id, Alignment, Length, Point}, + iced::widget::{column, container, scrollable, text}, + iced::{self, event, window, Subscription}, + prelude::*, + widget::{button, header_bar}, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Message { + CloseWindow(window::Id), + WindowOpened(window::Id, Option), + WindowClosed(window::Id), + NewWindow, + Input(id::Id, String), +} +pub struct MultiWindow { + core: Core, + windows: HashMap, +} + +pub struct Window { + input_id: id::Id, + input_value: String, +} + +impl cosmic::Application for MultiWindow { + type Executor = cosmic::executor::Default; + type Flags = (); + type Message = Message; + + const APP_ID: &'static str = "org.cosmic.MultiWindowDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn init(core: Core, _input: Self::Flags) -> (Self, cosmic::app::Task) { + let windows = MultiWindow { + windows: HashMap::from([( + core.main_window_id().unwrap(), + Window { + input_id: id::Id::new("main"), + input_value: String::new(), + }, + )]), + core, + }; + + (windows, cosmic::app::Task::none()) + } + + fn subscription(&self) -> Subscription { + event::listen_with(|event, _, id| { + if let iced::Event::Window(window_event) = event { + match window_event { + window::Event::CloseRequested => Some(Message::CloseWindow(id)), + window::Event::Opened { position, .. } => { + Some(Message::WindowOpened(id, position)) + } + window::Event::Closed => Some(Message::WindowClosed(id)), + _ => None, + } + } else { + None + } + }) + } + + fn update(&mut self, message: Self::Message) -> Task> { + match message { + Message::CloseWindow(id) => window::close(id), + Message::WindowClosed(id) => { + self.windows.remove(&id); + Task::none() + } + Message::WindowOpened(id, ..) => { + if let Some(window) = self.windows.get(&id) { + cosmic::widget::text_input::focus(window.input_id.clone()) + } else { + Task::none() + } + } + Message::NewWindow => { + let count = self.windows.len() + 1; + + let (id, spawn_window) = window::open(window::Settings { + position: Default::default(), + exit_on_close_request: count % 2 == 0, + decorations: false, + ..Default::default() + }); + + self.windows.insert( + id, + Window { + input_id: id::Id::new(format!("window_{}", count)), + input_value: String::new(), + }, + ); + _ = self.set_window_title(format!("window_{}", count), id); + + spawn_window.map(|id| cosmic::Action::App(Message::WindowOpened(id, None))) + } + Message::Input(id, value) => { + if let Some((_, w)) = self.windows.iter_mut().find(|e| e.1.input_id == id) { + w.input_value = value; + } + + Task::none() + } + } + } + + fn view_window(&self, id: window::Id) -> Element<'_, Self::Message> { + let w = self.windows.get(&id).unwrap(); + + let input_id = w.input_id.clone(); + let input = cosmic::widget::text_input::text_input("something", &w.input_value) + .on_input(move |msg| Message::Input(input_id.clone(), msg)) + .id(w.input_id.clone()); + let focused = self + .core() + .focused_window() + .map(|i| i == id) + .unwrap_or_default(); + let new_window_button = button::custom(text("New Window")).on_press(Message::NewWindow); + + let content = scrollable( + column![input, new_window_button] + .spacing(50) + .width(Length::Fill) + .align_x(Alignment::Center), + ); + + let window_content = container(container(content).center_x(Length::Fixed(200.))) + .class(cosmic::style::Container::Background) + .center_x(Length::Fill) + .center_y(Length::Fill); + + if id == self.core.main_window_id().unwrap() { + window_content.into() + } else { + column![header_bar().focused(focused), window_content].into() + } + } + + fn view(&self) -> Element<'_, Self::Message> { + self.view_window(self.core.main_window_id().unwrap()) + } +} diff --git a/examples/nav-context/Cargo.toml b/examples/nav-context/Cargo.toml new file mode 100644 index 00000000..d829df0f --- /dev/null +++ b/examples/nav-context/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "nav-context" +version = "0.1.0" +edition = "2021" + +[dependencies] +tracing = "0.1.44" +tracing-subscriber = "0.3.22" +tracing-log = "0.2.0" + +[dependencies.libcosmic] +path = "../../" +features = ["debug", "winit", "tokio", "xdg-portal", "wgpu"] diff --git a/examples/nav-context/src/main.rs b/examples/nav-context/src/main.rs new file mode 100644 index 00000000..1992066f --- /dev/null +++ b/examples/nav-context/src/main.rs @@ -0,0 +1,213 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Application API example + +use std::collections::HashMap; + +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::Size; +use cosmic::widget::{menu, nav_bar}; +use cosmic::{executor, iced, ApplicationExt, Element}; + +#[derive(Clone, Copy)] +pub enum Page { + Page1, + Page2, + Page3, + Page4, +} + +impl Page { + const fn as_str(self) -> &'static str { + match self { + Page::Page1 => "Page 1", + Page::Page2 => "Page 2", + Page::Page3 => "Page 3", + Page::Page4 => "Page 4", + } + } +} + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + let input = vec![ + (Page::Page1, "🖖 Hello from libcosmic.".into()), + (Page::Page2, "🌟 This is an example application.".into()), + (Page::Page3, "🚧 The libcosmic API is not stable yet.".into()), + (Page::Page4, "🚀 Copy the source code and experiment today!".into()), + ]; + + let settings = Settings::default() + .antialiasing(true) + .client_decorations(true) + .debug(false) + .default_icon_theme("Pop") + .default_text_size(16.0) + .scale_factor(1.0) + .size(Size::new(1024., 768.)); + + cosmic::app::run::(settings, input)?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + NavMenuAction(NavMenuAction), +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum NavMenuAction { + MoveUp(nav_bar::Id), + MoveDown(nav_bar::Id), + Delete(nav_bar::Id), +} + +impl menu::Action for NavMenuAction { + type Message = cosmic::Action; + + fn message(&self) -> Self::Message { + cosmic::Action::App(Message::NavMenuAction(*self)) + } +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + nav_model: nav_bar::Model, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = Vec<(Page, String)>; + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.AppDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, input: Self::Flags) -> (Self, Task) { + let mut nav_model = nav_bar::Model::default(); + + for (title, content) in input { + nav_model.insert().text(title.as_str()).data(content); + } + + nav_model.activate_position(0); + + let mut app = App { core, nav_model }; + + let command = app.update_title(); + + (app, command) + } + + /// Allows COSMIC to integrate with your application's [`nav_bar::Model`]. + fn nav_model(&self) -> Option<&nav_bar::Model> { + Some(&self.nav_model) + } + + /// The context menu to display for the given nav bar item ID. + fn nav_context_menu( + &self, + id: nav_bar::Id, + ) -> Option>>> { + Some(menu::items( + &HashMap::new(), + vec![ + menu::Item::Button("Move Up", None, NavMenuAction::MoveUp(id)), + menu::Item::Button("Move Down", None, NavMenuAction::MoveDown(id)), + menu::Item::Button("Delete", None, NavMenuAction::Delete(id)), + ], + )) + } + + /// Called when a navigation item is selected. + fn on_nav_select(&mut self, id: nav_bar::Id) -> Task { + self.nav_model.activate(id); + self.update_title() + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::NavMenuAction(message) => match message { + NavMenuAction::Delete(id) => self.nav_model.remove(id), + NavMenuAction::MoveUp(id) => { + if let Some(pos) = self.nav_model.position(id) { + if pos != 0 { + self.nav_model.position_set(id, pos - 1); + } + } + } + NavMenuAction::MoveDown(id) => { + if let Some(pos) = self.nav_model.position(id) { + self.nav_model.position_set(id, pos + 1); + } + } + }, + } + + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element<'_, Self::Message> { + let page_content = self + .nav_model + .active_data::() + .map_or("No page selected", String::as_str); + + let text = cosmic::widget::text(page_content); + + let centered = cosmic::widget::container(text) + .width(iced::Length::Fill) + .height(iced::Length::Shrink) + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); + + Element::from(centered) + } +} + +impl App +where + Self: cosmic::Application, +{ + fn active_page_title(&mut self) -> &str { + self.nav_model + .text(self.nav_model.active()) + .unwrap_or("Unknown Page") + } + + fn update_title(&mut self) -> Task { + let header_title = self.active_page_title().to_owned(); + let window_title = format!("{header_title} — COSMIC AppDemo"); + self.set_header_title(header_title); + if let Some(win_id) = self.core.main_window_id() { + self.set_window_title(window_title, win_id) + } else { + Task::none() + } + } +} diff --git a/examples/open-dialog/Cargo.toml b/examples/open-dialog/Cargo.toml index 7e1a52dc..94049270 100644 --- a/examples/open-dialog/Cargo.toml +++ b/examples/open-dialog/Cargo.toml @@ -3,14 +3,18 @@ name = "open-dialog" version = "0.1.0" edition = "2021" +[features] +default = ["xdg-portal"] +rfd = ["libcosmic/rfd"] +xdg-portal = ["libcosmic/xdg-portal"] + [dependencies] apply = "0.3.0" -tokio = { version = "1.31", features = ["full"] } -tracing = "0.1.37" -tracing-subscriber = "0.3.17" -url = "2.4.0" +tokio = { version = "1.49", features = ["full"] } +tracing = "0.1.44" +tracing-subscriber = "0.3.22" +url = "2.5.8" [dependencies.libcosmic] +features = ["debug", "winit", "wgpu", "wayland", "tokio"] path = "../../" -default-features = false -features = ["debug", "wayland", "tokio", "xdg-portal"] diff --git a/examples/open-dialog/src/main.rs b/examples/open-dialog/src/main.rs index a9901413..b4b5343f 100644 --- a/examples/open-dialog/src/main.rs +++ b/examples/open-dialog/src/main.rs @@ -4,11 +4,12 @@ //! An application which provides an open dialog use apply::Apply; -use cosmic::app::{Command, Core, Settings}; +use cosmic::app::{Core, Settings, Task}; use cosmic::dialog::file_chooser::{self, FileFilter}; -use cosmic::iced_core::Length; +use cosmic::iced::Length; use cosmic::widget::button; use cosmic::{executor, iced, ApplicationExt, Element}; +use std::sync::Arc; use tokio::io::AsyncReadExt; use url::Url; @@ -16,7 +17,7 @@ use url::Url; #[rustfmt::skip] fn main() -> Result<(), Box> { let settings = Settings::default() - .size((1024, 768)); + .size(cosmic::iced::Size::new(1024.0, 768.0)); cosmic::app::run::(settings, ())?; @@ -26,20 +27,19 @@ fn main() -> Result<(), Box> { /// Messages that are used specifically by our [`App`]. #[derive(Clone, Debug)] pub enum Message { + Cancelled, CloseError, - DialogClosed, - DialogInit(file_chooser::Sender), - DialogOpened, Error(String), FileRead(Url, String), + OpenError(Arc), OpenFile, Selected(Url), + Surface(cosmic::surface::Action), } /// The [`App`] stores application-specific state. pub struct App { core: Core, - open_sender: Option, file_contents: String, selected_file: Option, error_status: Option, @@ -66,70 +66,37 @@ impl cosmic::Application for App { &mut self.core } - /// Creates the application, and optionally emits command on initialize. - fn init(core: Core, _input: Self::Flags) -> (Self, Command) { + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Task) { + let id = core.main_window_id().unwrap(); let mut app = App { core, - open_sender: None, file_contents: String::new(), selected_file: None, error_status: None, }; app.set_header_title("Open a file".into()); - let cmd = app.set_window_title("COSMIC OpenDialog Demo".into()); + let cmd = app.set_window_title("COSMIC OpenDialog Demo".into(), id); (app, cmd) } - fn header_end(&self) -> Vec> { + fn header_end(&self) -> Vec> { // Places a button the header to create open dialogs. vec![button::suggested("Open").on_press(Message::OpenFile).into()] } - fn subscription(&self) -> cosmic::iced_futures::Subscription { - // Creates a subscription for handling open dialogs. - file_chooser::subscription(|response| match response { - file_chooser::Message::Closed => Message::DialogClosed, - file_chooser::Message::Opened => Message::DialogOpened, - file_chooser::Message::Selected(files) => match files.uris().first() { - Some(file) => Message::Selected(file.to_owned()), - None => Message::DialogClosed, - }, - file_chooser::Message::Init(sender) => Message::DialogInit(sender), - file_chooser::Message::Err(why) => { - let mut source: &dyn std::error::Error = &why; - let mut string = format!("open dialog subscription errored\n cause: {source}"); - - while let Some(new_source) = source.source() { - string.push_str(&format!("\n cause: {new_source}")); - source = new_source; - } - - Message::Error(string) - } - }) - } - - fn update(&mut self, message: Self::Message) -> Command { + fn update(&mut self, message: Self::Message) -> Task { match message { - Message::DialogClosed => { - eprintln!("dialog closed"); + Message::Cancelled => { + eprintln!("open file dialog cancelled"); } - - Message::DialogOpened => { - if let Some(sender) = self.open_sender.as_mut() { - eprintln!("requesting selection"); - return sender.response().map(|_| cosmic::app::Message::None); - } - } - Message::FileRead(url, contents) => { eprintln!("read file"); self.selected_file = Some(url); self.file_contents = contents; } - Message::Selected(url) => { eprintln!("selected file"); @@ -141,20 +108,23 @@ impl cosmic::Application for App { self.set_header_title(url.to_string()); // Reads the selected file into memory. - return cosmic::command::future(async move { + return cosmic::task::future(async move { // Check if its a valid local file path. let path = match url.scheme() { - "file" => url.path(), + "file" => url.to_file_path().unwrap(), other => { return Message::Error(format!("{url} has unknown scheme: {other}")); } }; // Open the file by its path. - let mut file = match tokio::fs::File::open(path).await { + let mut file = match tokio::fs::File::open(&path).await { Ok(file) => file, Err(why) => { - return Message::Error(format!("failed to open {path}: {why}")); + return Message::Error(format!( + "failed to open {}: {why}", + path.display() + )); } }; @@ -162,65 +132,71 @@ impl cosmic::Application for App { contents.clear(); if let Err(why) = file.read_to_string(&mut contents).await { - return Message::Error(format!("failed to read {path}: {why}")); + return Message::Error(format!("failed to read {}: {why}", path.display())); } contents.shrink_to_fit(); // Send this back to the application. Message::FileRead(url, contents) - }) - .map(cosmic::app::message::app); + }); } - - // Creates a new open dialog. Message::OpenFile => { - if let Some(sender) = self.open_sender.as_mut() { - if let Some(dialog) = file_chooser::open_file() { - eprintln!("opening new dialog"); + return cosmic::task::future(async move { + eprintln!("opening new dialog"); - return dialog - // Sets title of the dialog window. - .title("Choose a file".into()) - // Sets the label of the accept button. - .accept_label("_Open".into()) - // Exclude directories from file selection. - .include_directories(false) - // Defines whether to block the main window while requesting input. - .modal(false) - // Only accept one file as input. - .multiple_files(false) - // Accept only plain text files - .filter(FileFilter::new("Text files").mimetype("text/plain")) - // Emits the dialog to our sender - .create(sender) - // Ignores the output because it's empty. - .map(|_| cosmic::app::message::none()); + #[cfg(feature = "rfd")] + let filter = FileFilter::new("Text files").extension("txt"); + + #[cfg(feature = "xdg-portal")] + let filter = FileFilter::new("Text files").glob("*.txt"); + + let dialog = file_chooser::open::Dialog::new() + // Sets title of the dialog window. + .title("Choose a file") + // Accept only plain text files + .filter(filter); + + match dialog.open_file().await { + Ok(response) => Message::Selected(response.url().to_owned()), + + Err(file_chooser::Error::Cancelled) => Message::Cancelled, + + Err(why) => Message::OpenError(Arc::new(why)), } - } + }); } - - // Displays an error in the application's warning bar. Message::Error(why) => { self.error_status = Some(why); } + Message::OpenError(why) => { + if let Some(why) = Arc::into_inner(why) { + let mut source: &dyn std::error::Error = &why; + let mut string = + format!("open dialog subscription errored\n cause: {source}"); - // Closes the warning bar, if it was shown. + while let Some(new_source) = source.source() { + string.push_str(&format!("\n cause: {new_source}")); + source = new_source; + } + + self.error_status = Some(string); + } + } Message::CloseError => { self.error_status = None; } - - // The open dialog. subscription provides this on register. - Message::DialogInit(sender) => { - eprintln!("dialog subscription enabled"); - self.open_sender = Some(sender); + Message::Surface(action) => { + return cosmic::task::message(cosmic::Action::Cosmic( + cosmic::app::Action::Surface(action), + )); } } - Command::none() + Task::none() } - fn view(&self) -> Element { + fn view(&self) -> Element<'_, Self::Message> { let mut content = Vec::new(); if let Some(error) = self.error_status.as_deref() { @@ -229,7 +205,12 @@ impl cosmic::Application for App { .on_close(Message::CloseError) .into(), ); - content.push(iced::widget::vertical_space(Length::Fixed(12.0)).into()) + + content.push( + iced::widget::space::vertical() + .height(Length::Fixed(12.0)) + .into(), + ); } content.push(if self.selected_file.is_none() { @@ -249,7 +230,7 @@ fn center<'a>(input: impl Into> + 'a) -> Element<'a, Messag iced::widget::container(input.into()) .width(iced::Length::Fill) .height(iced::Length::Fill) - .align_x(iced::alignment::Horizontal::Center) - .align_y(iced::alignment::Vertical::Center) + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center) .into() } diff --git a/examples/spin-button/Cargo.toml b/examples/spin-button/Cargo.toml new file mode 100644 index 00000000..a522050b --- /dev/null +++ b/examples/spin-button/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "spin-button" +version = "0.1.0" +edition = "2021" + +[dependencies] +fraction = "0.15.3" + +[dependencies.libcosmic] +features = ["debug", "wgpu", "winit", "desktop", "tokio"] +path = "../.." +default-features = false diff --git a/examples/spin-button/src/main.rs b/examples/spin-button/src/main.rs new file mode 100644 index 00000000..47db4dce --- /dev/null +++ b/examples/spin-button/src/main.rs @@ -0,0 +1,201 @@ +use cosmic::iced::Length; +use cosmic::widget::{column, container, spin_button}; +use cosmic::Apply; +use cosmic::{ + app::{Core, Task}, + iced::{ + self, + alignment::{Horizontal, Vertical}, + Alignment, Size, + }, + Application, Element, +}; +use fraction::Decimal; + +pub struct SpinButtonExamplApp { + core: Core, + i8_num: i8, + i8_str: String, + i16_num: i16, + i16_str: String, + i32_num: i32, + i32_str: String, + i64_num: i64, + i64_str: String, + i128_num: i128, + i128_str: String, + f32_num: f32, + f32_str: String, + f64_num: f64, + f64_str: String, + dec_num: Decimal, + dec_str: String, +} + +#[derive(Debug, Clone)] +pub enum Message { + UpdateI8(i8), + UpdateI16(i16), + UpdateI32(i32), + UpdateI64(i64), + UpdateI128(i128), + UpdateF32(f32), + UpdateF64(f64), + UpdateDec(Decimal), +} + +impl Application for SpinButtonExamplApp { + type Executor = cosmic::executor::Default; + type Flags = (); + type Message = Message; + + const APP_ID: &'static str = "com.system76.SpinButtonExample"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn init(core: Core, _flags: Self::Flags) -> (Self, Task) { + ( + Self { + core, + i8_num: 0, + i8_str: 0.to_string(), + i16_num: 0, + i16_str: 0.to_string(), + i32_num: 0, + i32_str: 0.to_string(), + i64_num: 15, + i64_str: 15.to_string(), + i128_num: 0, + i128_str: 0.to_string(), + f32_num: 0., + f32_str: format!("{:.02}", 0.0), + f64_num: 0., + f64_str: format!("{:.02}", 0.0), + dec_num: Decimal::from(0.0), + dec_str: format!("{:.02}", 0.0), + }, + Task::none(), + ) + } + + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::UpdateI8(value) => { + self.i8_num = value; + self.i8_str = value.to_string(); + } + + Message::UpdateI16(value) => { + self.i16_num = value; + self.i16_str = value.to_string(); + } + + Message::UpdateI32(value) => { + self.i32_num = value; + self.i32_str = value.to_string(); + } + + Message::UpdateI64(value) => { + self.i64_num = value; + self.i64_str = value.to_string(); + } + + Message::UpdateI128(value) => { + self.i128_num = value; + self.i128_str = value.to_string(); + } + + Message::UpdateF32(value) => { + self.f32_num = value; + self.f32_str = format!("{value:.02}"); + } + + Message::UpdateF64(value) => { + self.f64_num = value; + self.f64_str = format!("{value:.02}"); + } + + Message::UpdateDec(value) => { + self.dec_num = value; + self.dec_str = format!("{value:.02}"); + } + } + + Task::none() + } + + fn view(&'_ self) -> Element<'_, Self::Message> { + let space_xs = cosmic::theme::spacing().space_xs; + + let vert_spinner_row = iced::widget::row![ + spin_button::vertical(&self.i8_str, self.i8_num, 1, -5, 5, Message::UpdateI8), + spin_button::vertical(&self.i16_str, self.i16_num, 1, 0, 10, Message::UpdateI16), + spin_button::vertical(&self.i32_str, self.i32_num, 1, 0, 12, Message::UpdateI32), + spin_button::vertical(&self.i64_str, self.i64_num, 10, 15, 35, Message::UpdateI64), + ] + .spacing(space_xs) + .align_y(Vertical::Center); + + let horiz_spinner_row = iced::widget::column![ + spin_button( + &self.i128_str, + self.i128_num, + 100, + -1000, + 500, + Message::UpdateI128 + ), + spin_button( + &self.f32_str, + self.f32_num, + 1.3, + -35.3, + 12.3, + Message::UpdateF32 + ), + spin_button( + &self.f64_str, + self.f64_num, + 1.3, + 0.0, + 3.0, + Message::UpdateF64 + ), + spin_button( + &self.dec_str, + self.dec_num, + Decimal::from(0.25), + Decimal::from(-5.0), + Decimal::from(5.0), + Message::UpdateDec + ), + ] + .spacing(space_xs) + .align_x(Alignment::Center); + + column::with_capacity(3) + .push(vert_spinner_row) + .push(horiz_spinner_row) + .spacing(space_xs) + .align_x(Alignment::Center) + .apply(container) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .into() + } +} + +fn main() -> Result<(), Box> { + let settings = cosmic::app::Settings::default().size(Size::new(550., 1024.)); + cosmic::app::run::(settings, ())?; + + Ok(()) +} diff --git a/examples/subscriptions/Cargo.toml b/examples/subscriptions/Cargo.toml new file mode 100644 index 00000000..8eb69ff3 --- /dev/null +++ b/examples/subscriptions/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "subscriptions" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[dependencies.libcosmic] +path = "../../" +features = ["debug", "winit", "wgpu", "tokio", "xdg-portal"] diff --git a/examples/subscriptions/src/main.rs b/examples/subscriptions/src/main.rs new file mode 100644 index 00000000..17e630aa --- /dev/null +++ b/examples/subscriptions/src/main.rs @@ -0,0 +1,80 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Application API example + +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::Subscription; +use cosmic::{executor, prelude::*, widget}; + +/// Runs application with these settings +fn main() -> Result<(), Box> { + cosmic::app::run::(Settings::default(), ())?; + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message {} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.TextInputsDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Task) { + let mut app = App { core }; + + let commands = Task::batch(vec![app.update_title()]); + + (app, commands) + } + + fn subscription(&self) -> Subscription { + Subscription::none() + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element<'_, Self::Message> { + widget::Row::new().into() + } +} + +impl App +where + Self: cosmic::Application, +{ + fn update_title(&mut self) -> Task { + let window_title = format!("COSMIC Subscriptions Demo"); + self.set_header_title(window_title.clone()); + self.set_window_title(window_title, self.core.main_window_id().unwrap()) + } +} diff --git a/examples/table-view/Cargo.toml b/examples/table-view/Cargo.toml new file mode 100644 index 00000000..8ed45928 --- /dev/null +++ b/examples/table-view/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "table-view" +version = "0.1.0" +edition = "2021" + +[dependencies] +tracing = "0.1.44" +tracing-subscriber = "0.3.22" +tracing-log = "0.2.0" +chrono = "*" + +[dependencies.libcosmic] +features = ["debug", "wgpu", "winit", "desktop", "tokio"] +path = "../.." diff --git a/examples/table-view/src/main.rs b/examples/table-view/src/main.rs new file mode 100644 index 00000000..d2478429 --- /dev/null +++ b/examples/table-view/src/main.rs @@ -0,0 +1,272 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Table API example + +use std::collections::HashMap; + +use chrono::Datelike; +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::Size; +use cosmic::prelude::*; +use cosmic::widget::table; +use cosmic::widget::{self, nav_bar}; +use cosmic::{executor, iced}; + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Hash)] +pub enum Category { + #[default] + Name, + Date, + Size, +} + +impl std::fmt::Display for Category { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Name => "Name", + Self::Date => "Date", + Self::Size => "Size", + }) + } +} + +impl table::ItemCategory for Category { + fn width(&self) -> iced::Length { + match self { + Self::Name => iced::Length::Fill, + Self::Date => iced::Length::Fixed(200.0), + Self::Size => iced::Length::Fixed(150.0), + } + } +} + +struct Item { + name: String, + date: chrono::DateTime, + size: u64, +} + +impl Default for Item { + fn default() -> Self { + Self { + name: Default::default(), + date: Default::default(), + size: Default::default(), + } + } +} + +impl table::ItemInterface for Item { + fn get_icon(&self, category: Category) -> Option { + if category == Category::Name { + Some(cosmic::widget::icon::from_name("application-x-executable-symbolic").icon()) + } else { + None + } + } + + fn get_text(&self, category: Category) -> std::borrow::Cow<'static, str> { + match category { + Category::Name => self.name.clone().into(), + Category::Date => self.date.format("%Y/%m/%d").to_string().into(), + Category::Size => format!("{} items", self.size).into(), + } + } + + fn compare(&self, other: &Self, category: Category) -> std::cmp::Ordering { + match category { + Category::Name => self.name.to_lowercase().cmp(&other.name.to_lowercase()), + Category::Date => self.date.cmp(&other.date), + Category::Size => self.size.cmp(&other.size), + } + } +} + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + let settings = Settings::default() + .size(Size::new(1024., 768.)); + + cosmic::app::run::(settings, ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + ItemSelect(table::Entity), + CategorySelect(Category), + PrintMsg(String), + NoOp, +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + table_model: table::SingleSelectModel, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.AppDemoTable"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _: Self::Flags) -> (Self, Task) { + let mut nav_model = nav_bar::Model::default(); + + nav_model.activate_position(0); + + let mut table_model = + table::Model::new(vec![Category::Name, Category::Date, Category::Size]); + + let _ = table_model.insert(Item { + name: "Foo".into(), + date: chrono::DateTime::default() + .with_day(1) + .unwrap() + .with_month(1) + .unwrap() + .with_year(1970) + .unwrap(), + size: 2, + }); + let _ = table_model.insert(Item { + name: "Bar".into(), + date: chrono::DateTime::default() + .with_day(2) + .unwrap() + .with_month(1) + .unwrap() + .with_year(1970) + .unwrap(), + size: 4, + }); + let _ = table_model.insert(Item { + name: "Baz".into(), + date: chrono::DateTime::default() + .with_day(3) + .unwrap() + .with_month(1) + .unwrap() + .with_year(1970) + .unwrap(), + size: 12, + }); + + let app = App { core, table_model }; + + let command = Task::none(); + + (app, command) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::ItemSelect(entity) => self.table_model.activate(entity), + Message::CategorySelect(category) => { + let mut ascending = true; + if let Some(old_sort) = self.table_model.get_sort() { + if old_sort.0 == category { + ascending = !old_sort.1; + } + } + self.table_model.sort(category, ascending) + } + Message::PrintMsg(string) => tracing_log::log::info!("{}", string), + Message::NoOp => {} + } + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element<'_, Self::Message> { + cosmic::widget::responsive(|size| { + if size.width < 600.0 { + widget::compact_table(&self.table_model) + .on_item_left_click(Message::ItemSelect) + .item_context(move |item| { + Some(widget::menu::items( + &HashMap::new(), + vec![widget::menu::Item::Button( + format!("Action on {}", item.name.to_string()), + None, + Action::None, + )], + )) + }) + .apply(Element::from) + } else { + widget::table(&self.table_model) + .on_item_left_click(Message::ItemSelect) + .on_category_left_click(Message::CategorySelect) + .item_context(|item| { + Some(widget::menu::items( + &HashMap::new(), + vec![widget::menu::Item::Button( + format!("Action on {}", item.name), + None, + Action::None, + )], + )) + }) + .category_context(|category| { + Some(widget::menu::items( + &HashMap::new(), + vec![ + widget::menu::Item::Button( + format!("Action on {} category", category.to_string()), + None, + Action::None, + ), + widget::menu::Item::Button( + format!("Other action on {} category", category.to_string()), + None, + Action::None, + ), + ], + )) + }) + .apply(Element::from) + } + }) + .into() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Action { + None, +} + +impl widget::menu::Action for Action { + type Message = Message; + + fn message(&self) -> Self::Message { + Message::NoOp + } +} diff --git a/examples/text-input/Cargo.toml b/examples/text-input/Cargo.toml new file mode 100644 index 00000000..fe6105c2 --- /dev/null +++ b/examples/text-input/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "text-input" +version = "0.1.0" +edition = "2021" + +[dependencies] +tracing = "0.1.44" +tracing-subscriber = "0.3.22" +tracing-log = "0.2.0" + +[dependencies.libcosmic] +path = "../../" +features = ["debug", "winit", "wgpu", "tokio", "xdg-portal"] diff --git a/examples/text-input/src/main.rs b/examples/text-input/src/main.rs new file mode 100644 index 00000000..c17fcd5c --- /dev/null +++ b/examples/text-input/src/main.rs @@ -0,0 +1,125 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Application API example + +use cosmic::app::{Core, Settings, Task}; +use cosmic::{executor, iced, ApplicationExt, Element}; + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + cosmic::app::run::(Settings::default(), ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + EditMode(bool), + Input(String), +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + input: String, + editing: bool, + search_id: cosmic::widget::Id, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.TextInputsDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Task) { + let mut app = App { + core, + editing: false, + input: String::from("Test"), + search_id: cosmic::widget::Id::unique(), + }; + + let commands = Task::batch(vec![ + cosmic::widget::text_input::focus(app.search_id.clone()), + app.update_title(), + ]); + + (app, commands) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::Input(text) => { + self.input = text; + } + + Message::EditMode(editing) => { + self.editing = editing; + } + } + + Task::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element<'_, Self::Message> { + let editable = cosmic::widget::editable_input( + "Input text here", + &self.input, + self.editing, + Message::EditMode, + ) + .on_input(Message::Input) + .id(self.search_id.clone()); + + let inline = cosmic::widget::inline_input("", &self.input).on_input(Message::Input); + + let column = cosmic::widget::column::with_capacity(2) + .push(editable) + .push(inline); + + let centered = cosmic::widget::container(column.width(200)) + .width(iced::Length::Fill) + .height(iced::Length::Shrink) + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); + + Element::from(centered) + } +} + +impl App +where + Self: cosmic::Application, +{ + fn update_title(&mut self) -> Task { + let window_title = format!("COSMIC TextInputs Demo"); + self.set_header_title(window_title.clone()); + self.set_window_title(window_title, self.core.main_window_id().unwrap()) + } +} diff --git a/i18n.toml b/i18n.toml new file mode 100644 index 00000000..76f7c310 --- /dev/null +++ b/i18n.toml @@ -0,0 +1,4 @@ +fallback_language = "en" + +[fluent] +assets_dir = "i18n" diff --git a/i18n/af/libcosmic.ftl b/i18n/af/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/ar/libcosmic.ftl b/i18n/ar/libcosmic.ftl new file mode 100644 index 00000000..35e6050f --- /dev/null +++ b/i18n/ar/libcosmic.ftl @@ -0,0 +1,36 @@ +# Context Drawer +close = أغلِق +# About +license = الترخيص +links = الروابط +developers = المطوِّرون +designers = المصمّمون +artists = الفنانون +translators = المترجمون +documenters = الموثقون +january = يناير { $year } +february = فبراير { $year } +march = مارس { $year } +april = ابريل { $year } +may = مايو { $year } +june = يونيو { $year } +july = يوليو { $year } +august = أغسطس { $year } +september = سبتمبر { $year } +october = أكتوبر { $year } +november = نوفمبر { $year } +december = ديسمبر { $year } +monday = الاثنين +tuesday = الثلاثاء +wednesday = الأربعاء +thursday = الخميس +friday = الجمعة +saturday = السبت +sunday = الأحد +mon = ن +tue = ث +wed = ر +thu = خ +fri = ج +sat = س +sun = ح diff --git a/i18n/be/libcosmic.ftl b/i18n/be/libcosmic.ftl new file mode 100644 index 00000000..1682a174 --- /dev/null +++ b/i18n/be/libcosmic.ftl @@ -0,0 +1,27 @@ +close = Закрыць +license = Ліцэнзія +links = Спасылкі +developers = Распрацоўшчыкі +designers = Дызайнеры +artists = Мастакі +translators = Перакладчыкі +documenters = Дакументалісты +february = Люты { $year } +november = Лістапад { $year } +friday = Пт +tuesday = Аў +may = Май { $year } +wednesday = Ср +april = Красавік { $year } +monday = Пн +december = Снежань { $year } +sunday = Нд +march = Сакавік { $year } +june = Чэрвень { $year } +saturday = Сб +august = Жнівень { $year } +july = Ліпень { $year } +thursday = Чц +september = Верасень { $year } +october = Кастрычнік { $year } +january = Студзень { $year } diff --git a/i18n/bg/libcosmic.ftl b/i18n/bg/libcosmic.ftl new file mode 100644 index 00000000..ab5ffb56 --- /dev/null +++ b/i18n/bg/libcosmic.ftl @@ -0,0 +1,29 @@ +# Context Drawer +close = Затваряне +# About +license = Лиценз +links = Връзки +developers = Разработчици +designers = Дизайнери +artists = Художници +translators = Преводачи +documenters = Документатори +january = Януари { $year } +february = Февруари { $year } +march = Март { $year } +april = Април { $year } +may = Май { $year } +june = Юни { $year } +july = Юли { $year } +august = Август { $year } +september = Септември { $year } +october = Октомври { $year } +november = Ноември { $year } +december = Декември { $year } +monday = Пн +tuesday = Вт +wednesday = Ср +thursday = Чт +friday = Пт +saturday = Сб +sunday = Нд diff --git a/i18n/bn/libcosmic.ftl b/i18n/bn/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/ca/libcosmic.ftl b/i18n/ca/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/cs/libcosmic.ftl b/i18n/cs/libcosmic.ftl new file mode 100644 index 00000000..850870d9 --- /dev/null +++ b/i18n/cs/libcosmic.ftl @@ -0,0 +1,36 @@ +# Context Drawer +close = Zavřít +# About +license = Licence +links = Odkazy +developers = Vývojáři +designers = Designéři +artists = Grafici +translators = Překladatelé +documenters = Tvůrci dokumentace +sunday = Neděle +january = Leden { $year } +february = Únor { $year } +march = Březen { $year } +april = Duben { $year } +may = Květen { $year } +june = Červen { $year } +july = Červenec { $year } +august = Srpen { $year } +september = Září { $year } +october = Říjen { $year } +november = Listopad { $year } +december = Prosinec { $year } +monday = Pondělí +tuesday = Úterý +wednesday = Středa +thursday = Čtvrtek +friday = Pátek +saturday = Sobota +mon = Po +tue = Út +wed = St +thu = Čt +fri = Pá +sat = So +sun = Ne diff --git a/i18n/da/libcosmic.ftl b/i18n/da/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/de/libcosmic.ftl b/i18n/de/libcosmic.ftl new file mode 100644 index 00000000..2d3704a6 --- /dev/null +++ b/i18n/de/libcosmic.ftl @@ -0,0 +1,37 @@ +# Context Drawer +close = Schließen +# About +license = Lizenz +links = Links +developers = Entwickler(innen) +designers = Designer(innen) +artists = Künstler(innen) +translators = Übersetzer(innen) +documenters = Dokumentierer(innen) +# Calendar +january = Januar { $year } +february = Februar { $year } +march = März { $year } +april = April { $year } +may = Mai { $year } +june = Juni { $year } +july = Juli { $year } +august = August { $year } +september = September { $year } +october = Oktober { $year } +november = November { $year } +december = Dezember { $year } +monday = Montag +tuesday = Dienstag +wednesday = Mittwoch +thursday = Donnerstag +friday = Freitag +saturday = Samstag +sunday = Sonntag +wed = Mi +thu = Do +fri = Fr +sat = Sa +sun = So +tue = Di +mon = Mo diff --git a/i18n/el/libcosmic.ftl b/i18n/el/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/en-GB/libcosmic.ftl b/i18n/en-GB/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/en/libcosmic.ftl b/i18n/en/libcosmic.ftl new file mode 100644 index 00000000..257fc44f --- /dev/null +++ b/i18n/en/libcosmic.ftl @@ -0,0 +1,39 @@ +# Context Drawer +close = Close + +# About +license = License +links = Links +developers = Developers +designers = Designers +artists = Artists +translators = Translators +documenters = Documenters + +# Calendar +january = January { $year } +february = February { $year } +march = March { $year } +april = April { $year } +may = May { $year } +june = June { $year } +july = July { $year } +august = August { $year } +september = September { $year } +october = October { $year } +november = November { $year } +december = December { $year } +monday = Monday +mon = Mon +tuesday = Tuesday +tue = Tue +wednesday = Wednesday +wed = Wed +thursday = Thursday +thu = Thu +friday = Friday +fri = Fri +saturday = Saturday +sat = Sat +sunday = Sunday +sun = Sun diff --git a/i18n/eo/libcosmic.ftl b/i18n/eo/libcosmic.ftl new file mode 100644 index 00000000..69764d88 --- /dev/null +++ b/i18n/eo/libcosmic.ftl @@ -0,0 +1,11 @@ +# Context Drawer +close = Fermi + +# About +license = Permesilo +links = Ligiloj +developers = Programistoj +designers = Grafikistoj +artists = Artistoj +translators = Tradukantoj +documenters = Dokumentantoj diff --git a/i18n/es-419/libcosmic.ftl b/i18n/es-419/libcosmic.ftl new file mode 100644 index 00000000..8ef988e9 --- /dev/null +++ b/i18n/es-419/libcosmic.ftl @@ -0,0 +1,8 @@ +close = Cerrar +license = Licencia +links = Enlaces +developers = Desarrolladores +designers = Diseñadores +artists = Artistas +translators = Traductores +documenters = Documentalistas diff --git a/i18n/es-MX/libcosmic.ftl b/i18n/es-MX/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/es/libcosmic.ftl b/i18n/es/libcosmic.ftl new file mode 100644 index 00000000..3e6e337d --- /dev/null +++ b/i18n/es/libcosmic.ftl @@ -0,0 +1,8 @@ +license = Licencia +links = Enlaces +developers = Desarrolladores +designers = Diseñadores +artists = Artistas +translators = Traductores +documenters = Documentadores +close = Cerrar diff --git a/i18n/et/libcosmic.ftl b/i18n/et/libcosmic.ftl new file mode 100644 index 00000000..38b16698 --- /dev/null +++ b/i18n/et/libcosmic.ftl @@ -0,0 +1,8 @@ +close = Sulge +license = Litsents +links = Lingid +developers = Arendajad +artists = Kunstnikud +translators = Tõlkijad +documenters = Dokumenteerijad +designers = Kujundajad diff --git a/i18n/eu/libcosmic.ftl b/i18n/eu/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/fa/libcosmic.ftl b/i18n/fa/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/fi/libcosmic.ftl b/i18n/fi/libcosmic.ftl new file mode 100644 index 00000000..877f225d --- /dev/null +++ b/i18n/fi/libcosmic.ftl @@ -0,0 +1,34 @@ +monday = Maanantai +mon = ma +tuesday = Tiistai +tue = ti +wednesday = Keskiviikko +wed = ke +thursday = Torstai +thu = to +friday = Perjantai +fri = pe +saturday = Lauantai +sat = la +sunday = Sunnuntai +sun = su +close = Sulje +license = Lisenssi +links = Linkit +developers = Kehittäjät +designers = Suunnittelijat +artists = Artistit +translators = Kääntäjät +documenters = Dokumentoijat +january = Tammikuu { $year } +february = Helmikuu { $year } +march = Maaliskuu { $year } +april = Huhtikuu { $year } +may = Toukokuu { $year } +june = Kesäkuu { $year } +july = Heinäkuu { $year } +august = Elokuu { $year } +september = Syyskuu { $year } +october = Lokakuu { $year } +november = Marraskuu { $year } +december = Joulukuu { $year } diff --git a/i18n/fr/libcosmic.ftl b/i18n/fr/libcosmic.ftl new file mode 100644 index 00000000..1ec6c0cf --- /dev/null +++ b/i18n/fr/libcosmic.ftl @@ -0,0 +1,34 @@ +close = Fermer +documenters = Rédacteurs +translators = Traducteurs +artists = Artistes +license = Licence +links = Liens +developers = Développeurs +january = Janvier { $year } +february = Février { $year } +april = Avril { $year } +march = Mars { $year } +november = Novembre { $year } +friday = Vendredi +tuesday = Mardi +may = Mai { $year } +wednesday = Mercredi +monday = Lundi +december = Décembre { $year } +sunday = Dimanche +june = Juin { $year } +saturday = Samedi +august = Août { $year } +july = Juillet { $year } +thursday = Jeudi +september = Septembre { $year } +october = Octobre { $year } +designers = Designers +mon = Lun +tue = Mar +wed = Mer +thu = Jeu +fri = Ven +sat = Sam +sun = Dim diff --git a/i18n/fy/libcosmic.ftl b/i18n/fy/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/ga/libcosmic.ftl b/i18n/ga/libcosmic.ftl new file mode 100644 index 00000000..bdf38d20 --- /dev/null +++ b/i18n/ga/libcosmic.ftl @@ -0,0 +1,34 @@ +close = Dún +license = Ceadúnas +links = Naisc +developers = Forbróirí +designers = Dearthóirí +artists = Ealaíontóirí +translators = Aistritheoirí +documenters = Doiciméadóirí +january = Eanáir { $year } +february = Feabhra { $year } +march = Márta { $year } +april = Aibreán { $year } +may = Bealtaine { $year } +june = Meitheamh { $year } +july = Iúil { $year } +august = Lúnasa { $year } +september = Meán Fómhair { $year } +october = Deireadh Fómhair { $year } +november = Samhain { $year } +december = Nollaig { $year } +monday = Dé Luain +tuesday = Dé Máirt +wednesday = Dé Céadaoin +thursday = Déardaoin +friday = Dé hAoine +saturday = Dé Sathairn +sunday = Dé Domhnaigh +mon = Lua +tue = Mái +wed = Céa +thu = Déa +fri = Aoi +sat = Sat +sun = Dom diff --git a/i18n/gd/libcosmic.ftl b/i18n/gd/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/gu/libcosmic.ftl b/i18n/gu/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/he/libcosmic.ftl b/i18n/he/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/hi/libcosmic.ftl b/i18n/hi/libcosmic.ftl new file mode 100644 index 00000000..8603e773 --- /dev/null +++ b/i18n/hi/libcosmic.ftl @@ -0,0 +1,12 @@ +close = बंद करें +license = लाइसेंस +links = लिंक +developers = डेवलपर्स +designers = डिज़ाइनर +february = फ़रवरी { $year } +documenters = दस्तावेज़ बनाने वाले +april = अप्रैल { $year } +translators = अनुवादक +artists = कलाकार +march = मार्च { $year } +january = जनवरी { $year } diff --git a/i18n/hr/libcosmic.ftl b/i18n/hr/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/hu/libcosmic.ftl b/i18n/hu/libcosmic.ftl new file mode 100644 index 00000000..7ff046b3 --- /dev/null +++ b/i18n/hu/libcosmic.ftl @@ -0,0 +1,36 @@ +# Context Drawer +close = Bezárás +# About +license = Licenc +links = Hivatkozások +developers = Fejlesztők +designers = Tervezők +artists = Művészek +translators = Fordítók +documenters = Dokumentálók +january = { $year } január +february = { $year } február +march = { $year } március +april = { $year } április +may = { $year } május +june = { $year } június +july = { $year } július +august = { $year } augusztus +september = { $year } szeptember +october = { $year } október +november = { $year } november +december = { $year } december +monday = Hétfő +tuesday = Kedd +wednesday = Szerda +thursday = Csütörtök +friday = Péntek +saturday = Szombat +sunday = Vasárnap +mon = H +tue = K +wed = Sze +thu = Cs +fri = P +sat = Szo +sun = V diff --git a/i18n/id/libcosmic.ftl b/i18n/id/libcosmic.ftl new file mode 100644 index 00000000..53e7736b --- /dev/null +++ b/i18n/id/libcosmic.ftl @@ -0,0 +1,34 @@ +close = Tutup +license = Lisensi +links = Tautan +developers = Pengembang +designers = Perancang +artists = Artis +translators = Penerjemah +documenters = Dokumenter +january = Januari { $year } +february = Februari { $year } +march = Maret { $year } +april = April { $year } +may = Mei { $year } +june = Juni { $year } +july = Juli { $year } +august = Agustus { $year } +september = September { $year } +october = Oktober { $year } +november = November { $year } +december = Desember { $year } +monday = Senin +tuesday = Selasa +wednesday = Rabu +sunday = Minggu +saturday = Sabtu +friday = Jum'at +thursday = Kamis +mon = Sen +tue = Sel +wed = Rab +thu = Kam +fri = Jum +sat = Sab +sun = Min diff --git a/i18n/ie/libcosmic.ftl b/i18n/ie/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/is/libcosmic.ftl b/i18n/is/libcosmic.ftl new file mode 100644 index 00000000..391eaf08 --- /dev/null +++ b/i18n/is/libcosmic.ftl @@ -0,0 +1,8 @@ +close = Loka +license = Notandaleyfi +links = Tenglar +developers = Forritarar +designers = Hönnuðir +artists = Listafólk +translators = Þýðendur +documenters = Skjölunarhöfundar diff --git a/i18n/it/libcosmic.ftl b/i18n/it/libcosmic.ftl new file mode 100644 index 00000000..a551a716 --- /dev/null +++ b/i18n/it/libcosmic.ftl @@ -0,0 +1,8 @@ +close = Chiudi +license = Licenza +links = Link +developers = Sviluppatori +designers = Designer +artists = Artisti +translators = Traduttori +documenters = Documentatori diff --git a/i18n/ja/libcosmic.ftl b/i18n/ja/libcosmic.ftl new file mode 100644 index 00000000..c6b9ed1a --- /dev/null +++ b/i18n/ja/libcosmic.ftl @@ -0,0 +1,8 @@ +close = 閉じる +license = ライセンス +links = リンク +developers = 開発者 +designers = デザイナー +artists = アーティスト +translators = 翻訳者 +documenters = ドキュメント作成者 diff --git a/i18n/jv/libcosmic.ftl b/i18n/jv/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/ka/libcosmic.ftl b/i18n/ka/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/kab/libcosmic.ftl b/i18n/kab/libcosmic.ftl new file mode 100644 index 00000000..6eac2bc7 --- /dev/null +++ b/i18n/kab/libcosmic.ftl @@ -0,0 +1,33 @@ +close = Mdel +license = Turagt +links = Iseɣwan +developers = Ineflayen +artists = Inaẓuren +translators = Imsuqlen +january = Yennayer { $year } +february = Fuṛar { $year } +march = Meɣres { $year } +april = Yebrir { $year } +may = Mayyu { $year } +june = Yunyu { $year } +july = Yulyu { $year } +august = Ɣuct { $year } +september = Ctembeṛ { $year } +october = Tubeṛ { $year } +november = Wambeṛ { $year } +december = Dujembeṛ { $year } +documenters = Imeskaren +monday = Arim +mon = Ari +tuesday = Aram +tue = Ara +wednesday = Ahad +wed = Aha +thursday = Amhad +thu = Amh +friday = Sem +fri = Sm +saturday = Sed +sat = Sd +sunday = Acer +sun = Ace diff --git a/i18n/kk/libcosmic.ftl b/i18n/kk/libcosmic.ftl new file mode 100644 index 00000000..9d257114 --- /dev/null +++ b/i18n/kk/libcosmic.ftl @@ -0,0 +1,34 @@ +close = Жабу +license = Лицензия +links = Сілтемелер +developers = Әзірлеушілер +designers = Дизайнерлер +artists = Суретшілер +translators = Аудармашылар +documenters = Құжаттаушылар +january = Қаңтар { $year } +february = Ақпан { $year } +march = Наурыз { $year } +april = Сәуір { $year } +may = Мамыр { $year } +june = Маусым { $year } +july = Шілде { $year } +august = Тамыз { $year } +september = Қыркүйек { $year } +october = Қазан { $year } +november = Қараша { $year } +december = Желтоқсан { $year } +monday = Дүйсенбі +tuesday = Сейсенбі +wednesday = Сәрсенбі +thursday = Бейсенбі +friday = Жұма +saturday = Сенбі +sunday = Жексенбі +mon = Дс +tue = Сс +wed = Ср +thu = Бс +fri = Жм +sat = Сн +sun = Жк diff --git a/i18n/kmr/libcosmic.ftl b/i18n/kmr/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/kn/libcosmic.ftl b/i18n/kn/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/ko/libcosmic.ftl b/i18n/ko/libcosmic.ftl new file mode 100644 index 00000000..6cc0adbc --- /dev/null +++ b/i18n/ko/libcosmic.ftl @@ -0,0 +1,34 @@ +february = { $year }년 2월 +close = 닫기 +documenters = 문서 작성자 +november = { $year }년 11월 +friday = 금요일 +tuesday = 화요일 +may = { $year }년 5월 +wednesday = 수요일 +april = { $year }년 4월 +monday = 월요일 +translators = 번역가 +artists = 아티스트 +license = 라이선스 +december = { $year }년 12월 +sunday = 일요일 +links = 링크 +march = { $year }년 3월 +june = { $year }년 6월 +saturday = 토요일 +august = { $year }년 8월 +developers = 개발자 +july = { $year }년 7월 +thursday = 목요일 +september = { $year }년 9월 +designers = 디자이너 +october = { $year }년 10월 +january = { $year }년 1월 +mon = 월 +tue = 화 +wed = 수 +thu = 목 +fri = 금 +sat = 토 +sun = 일 diff --git a/i18n/li/libcosmic.ftl b/i18n/li/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/lt/libcosmic.ftl b/i18n/lt/libcosmic.ftl new file mode 100644 index 00000000..097b3219 --- /dev/null +++ b/i18n/lt/libcosmic.ftl @@ -0,0 +1,34 @@ +february = Vasaris { $year } +close = Uždaryti +documenters = Dokumentuotojai +november = Lapkritis { $year } +friday = Penktadienis +tuesday = Antradienis +may = Gegužė { $year } +wednesday = Trečiadienis +april = Balandis { $year } +monday = Pirmadienis +translators = Vertėjai +artists = Menininkai +license = Licencija +december = Gruodis { $year } +sunday = Sekmadienis +links = Nuorodos +march = Kovas { $year } +june = Birželis { $year } +saturday = Šeštadienis +august = Rugpjūtis { $year } +developers = Kūrėjai +july = Liepa { $year } +thursday = Ketvirtadienis +september = Rugsėjis { $year } +designers = Dizaineriai +october = Spalis { $year } +january = Sausis { $year } +mon = Pirm +tue = Antr +wed = Treč +thu = Ketv +fri = Penkt +sat = Šešt +sun = Sekm diff --git a/i18n/ml/libcosmic.ftl b/i18n/ml/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/ms/libcosmic.ftl b/i18n/ms/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/nb-NO/libcosmic.ftl b/i18n/nb-NO/libcosmic.ftl new file mode 100644 index 00000000..64d4e5d1 --- /dev/null +++ b/i18n/nb-NO/libcosmic.ftl @@ -0,0 +1,8 @@ +close = Lukk +license = Lisens +links = Linker +developers = Utviklere +designers = Designere +artists = Artister +translators = Oversettere +documenters = Dokumentører diff --git a/i18n/nl/libcosmic.ftl b/i18n/nl/libcosmic.ftl new file mode 100644 index 00000000..7676b811 --- /dev/null +++ b/i18n/nl/libcosmic.ftl @@ -0,0 +1,27 @@ +close = Sluiten +license = Licentie +january = Januari { $year } +february = Februari { $year } +march = Maart { $year } +april = April { $year } +may = Mei { $year } +june = Juni { $year } +july = Juli { $year } +august = Augustus { $year } +september = September { $year } +october = Oktober { $year } +november = November { $year } +december = December { $year } +monday = Ma +tuesday = Di +wednesday = Woe +thursday = Do +friday = Vrij +saturday = Za +sunday = Zo +links = Links +developers = Ontwikkeling +designers = Ontwerp +translators = Vertaling +documenters = Documentatie +artists = Vormgeving diff --git a/i18n/nn/libcosmic.ftl b/i18n/nn/libcosmic.ftl new file mode 100644 index 00000000..ffa3faf5 --- /dev/null +++ b/i18n/nn/libcosmic.ftl @@ -0,0 +1,2 @@ +close = Lukk +license = Lisens diff --git a/i18n/oc/libcosmic.ftl b/i18n/oc/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/pa/libcosmic.ftl b/i18n/pa/libcosmic.ftl new file mode 100644 index 00000000..83d82608 --- /dev/null +++ b/i18n/pa/libcosmic.ftl @@ -0,0 +1,34 @@ +close = ਬੰਦ ਕਰੋ +license = ਲਸੰਸ +links = ਲਿੰਕ +developers = ਡਿਵੈਲਪਰ +designers = ਡਿਜ਼ਾਇਨਰ +artists = ਕਲਾਕਾਰ +translators = ਅਨੁਵਾਦਕ +documenters = ਦਸਤਾਵੇਜ਼ ਤਿਆਰ ਕਰਤਾ +january = ਜਨਵਰੀ { $year } +february = ਫਰਵਰੀ { $year } +march = ਮਾਰਚ { $year } +april = ਅਪਰੈਲ { $year } +may = ਮਈ { $year } +june = ਜੂਨ { $year } +july = ਜੁਲਾਈ { $year } +august = ਅਗਸਤ { $year } +september = ਸਤੰਬਰ { $year } +october = ਅਕਤੂਬਰ { $year } +november = ਨਵੰਬਰ { $year } +december = ਦਸੰਬਰ { $year } +monday = ਸੋਮਵਾਰ +mon = ਸੋਮ +tuesday = ਮੰਗਲਵਾਰ +tue = ਮੰਗਲ +wednesday = ਬੁੱਧਵਾਰ +wed = ਬੁੱਧ +thursday = ਵੀਰਵਾਰ +thu = ਵੀਰ +friday = ਸ਼ੁੱਕਰਵਾਰ +fri = ਸ਼ੁੱਕਰ +saturday = ਸ਼ਨਿੱਚਰਵਾਰ +sat = ਸ਼ਨਿੱਚਰ +sunday = ਐਤਵਾਰ +sun = ਐਤ diff --git a/i18n/pl/libcosmic.ftl b/i18n/pl/libcosmic.ftl new file mode 100644 index 00000000..0d1649d4 --- /dev/null +++ b/i18n/pl/libcosmic.ftl @@ -0,0 +1,36 @@ +# Context Drawer +close = Zamknij +# About +license = Licencja +links = Linki +developers = Programiści +designers = Projektanci +artists = Artyści +translators = Tłumacze +documenters = Dokumentaliści +january = Styczeń { $year } +february = Luty { $year } +march = Marzec { $year } +april = Kwiecień { $year } +may = Maj { $year } +june = Czerwiec { $year } +july = Lipiec { $year } +august = Sierpień { $year } +september = Wrzesień { $year } +october = Październik { $year } +november = Listopad { $year } +december = Grudzień { $year } +monday = Poniedziałek +tuesday = Wtorek +wednesday = Środa +thursday = Czwartek +friday = Piątek +saturday = Sobota +sunday = Niedziela +mon = Pon +tue = Wto +wed = Śro +thu = Czw +fri = Pia +sat = Sob +sun = Nie diff --git a/i18n/pt-BR/libcosmic.ftl b/i18n/pt-BR/libcosmic.ftl new file mode 100644 index 00000000..1a51c799 --- /dev/null +++ b/i18n/pt-BR/libcosmic.ftl @@ -0,0 +1,36 @@ +# Context Drawer +close = Fechar +# About +license = Licença +links = Links +developers = Desenvolvedores +designers = Designers +artists = Artistas +translators = Tradutores +documenters = Documentadores +january = Janeiro de { $year } +february = Fevereiro de { $year } +march = Março de { $year } +april = Abril de { $year } +may = Maio de { $year } +june = Junho de { $year } +july = Julho de { $year } +august = Agosto de { $year } +september = Setembro de { $year } +october = Outubro de { $year } +november = Novembro de { $year } +december = Dezembro de { $year } +monday = Segunda-feira +tuesday = Terça-feira +wednesday = Quarta-feira +thursday = Quinta-feira +friday = Sexta-feira +saturday = Sábado +sunday = Domingo +mon = Seg +tue = Ter +wed = Qua +thu = Qui +fri = Sex +sat = Sáb +sun = Dom diff --git a/i18n/pt/libcosmic.ftl b/i18n/pt/libcosmic.ftl new file mode 100644 index 00000000..e1786efb --- /dev/null +++ b/i18n/pt/libcosmic.ftl @@ -0,0 +1,8 @@ +close = Fechar +license = Licença +links = Ligações +developers = Programadores +designers = Designers +artists = Artistas +translators = Tradutores +documenters = Documentadores diff --git a/i18n/ro/libcosmic.ftl b/i18n/ro/libcosmic.ftl new file mode 100644 index 00000000..da9f80a5 --- /dev/null +++ b/i18n/ro/libcosmic.ftl @@ -0,0 +1,11 @@ +# Context Drawer +close = Închide + +# About +license = Licență +links = Linkuri +developers = Dezvoltatori +designers = Designeri +artists = Artiști +translators = Traducători +documenters = Documentatori diff --git a/i18n/ru/libcosmic.ftl b/i18n/ru/libcosmic.ftl new file mode 100644 index 00000000..1ff78655 --- /dev/null +++ b/i18n/ru/libcosmic.ftl @@ -0,0 +1,34 @@ +close = Закрыть +license = Лицензия +links = Ссылки +developers = Разработчики +designers = Дизайнеры +artists = Художники +translators = Переводчики +documenters = Авторы документации +january = Январь { $year } +february = Февраль { $year } +march = Март { $year } +april = Апрель { $year } +may = Май { $year } +june = Июнь { $year } +july = Июль { $year } +august = Август { $year } +september = Сентябрь { $year } +october = Октябрь { $year } +november = Ноябрь { $year } +december = Декабрь { $year } +monday = Понедельник +tuesday = Вторник +wednesday = Среда +thursday = Четверг +friday = Пятница +saturday = Суббота +sunday = Воскресенье +mon = Пн +tue = Вт +wed = Ср +thu = Чт +fri = Пт +sat = Сб +sun = Вс diff --git a/i18n/sk/libcosmic.ftl b/i18n/sk/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/sl/libcosmic.ftl b/i18n/sl/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/sr-Cyrl/libcosmic.ftl b/i18n/sr-Cyrl/libcosmic.ftl new file mode 100644 index 00000000..ce6afb28 --- /dev/null +++ b/i18n/sr-Cyrl/libcosmic.ftl @@ -0,0 +1,10 @@ +# Context Drawer +close = Затвори +# About +license = Лиценца +links = Линкови +developers = Програмер +designers = Дизајнери +artists = Уметници +translators = Преводиоци +documenters = Произвођачи документације diff --git a/i18n/sr-Latn/libcosmic.ftl b/i18n/sr-Latn/libcosmic.ftl new file mode 100644 index 00000000..9fbe9a21 --- /dev/null +++ b/i18n/sr-Latn/libcosmic.ftl @@ -0,0 +1,11 @@ +# Context Drawer +close = Zatvori + +# About +license = Licenca +links = Linkovi +developers = Programeri +designers = Dizajneri +artists = Umetnici +translators = Prevodioci +documenters = Dokumentatori diff --git a/i18n/sr/libcosmic.ftl b/i18n/sr/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/sv/libcosmic.ftl b/i18n/sv/libcosmic.ftl new file mode 100644 index 00000000..27cdb393 --- /dev/null +++ b/i18n/sv/libcosmic.ftl @@ -0,0 +1,34 @@ +license = Licens +links = Länkar +developers = Utvecklare +designers = Designers +artists = Konstnärer +translators = Översättare +documenters = Skribenter +close = Stäng +january = Januari { $year } +february = Februari { $year } +march = Mars { $year } +april = April { $year } +may = Maj { $year } +june = Juni { $year } +july = Juli { $year } +august = Augusti { $year } +september = September { $year } +october = Oktober { $year } +november = November { $year } +december = December { $year } +monday = Måndag +tuesday = Tisdag +wednesday = Onsdag +thursday = Torsdag +friday = Fredag +saturday = Lördag +sunday = Söndag +sun = Sön +mon = Mån +tue = Tis +wed = Ons +thu = Tor +fri = Fre +sat = Lör diff --git a/i18n/ta/libcosmic.ftl b/i18n/ta/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/th/libcosmic.ftl b/i18n/th/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/ti/libcosmic.ftl b/i18n/ti/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/tr/libcosmic.ftl b/i18n/tr/libcosmic.ftl new file mode 100644 index 00000000..39690200 --- /dev/null +++ b/i18n/tr/libcosmic.ftl @@ -0,0 +1,36 @@ +# Context Drawer +close = Kapat +# About +license = Lisans +links = Bağlantılar +developers = Geliştiriciler +designers = Tasarımcılar +artists = Sanatçılar +translators = Çevirmenler +documenters = Belgelendiriciler +january = Ocak { $year } +february = Şubat { $year } +march = Mart { $year } +april = Nisan { $year } +may = Mayıs { $year } +june = Haziran { $year } +july = Temmuz { $year } +august = Ağustos { $year } +september = Eylül { $year } +october = Ekim { $year } +november = Kasım { $year } +december = Aralık { $year } +monday = Pazartesi +mon = Pzt +tuesday = Salı +tue = Sal +wednesday = Çarşamba +wed = Çar +thursday = Perşembe +thu = Per +friday = Cuma +fri = Cum +saturday = Cumartesi +sat = Cmt +sunday = Pazar +sun = Paz diff --git a/i18n/uk/libcosmic.ftl b/i18n/uk/libcosmic.ftl new file mode 100644 index 00000000..cbe1cfaf --- /dev/null +++ b/i18n/uk/libcosmic.ftl @@ -0,0 +1,36 @@ +# Context Drawer +close = Закрити +# About +license = Ліцензія +links = Ланки +developers = Розробники +designers = Дизайнери +artists = Художники +translators = Перекладачі +documenters = Документатори +february = Лютий { $year } +november = Листопад { $year } +friday = П'ятниця +tuesday = Вівторок +may = Травень { $year } +wednesday = Середа +april = Квітень { $year } +monday = Понеділок +december = Грудень { $year } +sunday = Неділя +march = Березень { $year } +june = Червень { $year } +saturday = Субота +august = Серпень { $year } +july = Липень { $year } +thursday = Четвер +september = Вересень { $year } +october = Жовтень { $year } +january = Січень { $year } +mon = Пн +tue = Вт +wed = Ср +thu = Чт +fri = Пт +sat = Cб +sun = Нд diff --git a/i18n/uz/libcosmic.ftl b/i18n/uz/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/vi/libcosmic.ftl b/i18n/vi/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/yue-Hant/libcosmic.ftl b/i18n/yue-Hant/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/zh-Hans/libcosmic.ftl b/i18n/zh-Hans/libcosmic.ftl new file mode 100644 index 00000000..42330dcb --- /dev/null +++ b/i18n/zh-Hans/libcosmic.ftl @@ -0,0 +1,34 @@ +close = 关闭 +license = 许可证 +links = 链接 +developers = 开发者 +designers = 设计师 +translators = 译者 +january = { $year }年1月 +february = { $year }年2月 +march = { $year }年3月 +april = { $year }年4月 +may = { $year }年5月 +june = { $year }年6月 +july = { $year }年7月 +august = { $year }年8月 +september = { $year }年9月 +october = { $year }年10月 +november = { $year }年11月 +december = { $year }年12月 +monday = 星期一 +tuesday = 星期二 +wednesday = 星期三 +thursday = 星期四 +friday = 星期五 +saturday = 星期六 +sunday = 星期日 +artists = 艺术家 +documenters = 文档作者 +mon = 周一 +tue = 周二 +wed = 周三 +thu = 周四 +fri = 周五 +sat = 周六 +sun = 周日 diff --git a/i18n/zh-Hant/libcosmic.ftl b/i18n/zh-Hant/libcosmic.ftl new file mode 100644 index 00000000..8c9b201c --- /dev/null +++ b/i18n/zh-Hant/libcosmic.ftl @@ -0,0 +1,34 @@ +close = 關閉 +developers = 開發人員 +designers = 設計人員 +artists = 美編設計 +translators = 翻譯人員 +documenters = 文件編輯人員 +january = { $year } 年 1 月 +monday = 星期一 +tuesday = 星期二 +wednesday = 星期三 +thursday = 星期四 +friday = 星期五 +saturday = 星期六 +sunday = 星期日 +mon = 週一 +tue = 週二 +wed = 週三 +thu = 週四 +fri = 週五 +sat = 週六 +sun = 週日 +license = 授權 +links = 連結 +february = { $year } 年 2 月 +march = { $year } 年 3 月 +april = { $year } 年 4 月 +may = { $year } 年 5 月 +june = { $year } 年 6 月 +july = { $year } 年 7 月 +august = { $year } 年 8 月 +september = { $year } 年 9 月 +october = { $year } 年 10 月 +november = { $year } 年 11 月 +december = { $year } 年 12 月 diff --git a/iced b/iced index b3ede4f9..78caabba 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit b3ede4f9a72275cfeb29fac80a31546f728783fd +Subproject commit 78caabba7ef91cd1030da6f70b41d266704ffece diff --git a/justfile b/justfile index b93bae64..4653434e 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,5 @@ -examples := 'application cosmic cosmic-design-demo open-dialog' +examples := 'applet application calendar config context-menu cosmic image-button menu multi-window nav-context open-dialog table-view' +clippy_args := '-W clippy::all -W clippy::pedantic' # Check for errors and linter warnings check *args: (check-wayland args) (check-winit args) (check-examples args) @@ -6,14 +7,14 @@ check *args: (check-wayland args) (check-winit args) (check-examples args) check-examples *args: #!/bin/bash for project in {{examples}}; do - cargo check -p ${project} {{args}} + cargo clippy -p ${project} {{args}} -- {{clippy_args}} done check-wayland *args: - cargo clippy --no-deps --features="wayland,tokio" {{args}} -- -W clippy::pedantic + cargo clippy --no-deps --features="wayland,tokio,xdg-portal" {{args}} -- {{clippy_args}} check-winit *args: - cargo clippy --no-deps --features="winit,tokio" {{args}} -- -W clippy::pedantic + cargo clippy --no-deps --features="winit,tokio,xdg-portal" {{args}} -- {{clippy_args}} # Runs a check with JSON message format for IDE integration check-json: (check '--message-format=json') diff --git a/res/Fira/FiraMono-Regular.otf b/res/Fira/FiraMono-Regular.otf deleted file mode 100644 index c30b25b9..00000000 Binary files a/res/Fira/FiraMono-Regular.otf and /dev/null differ diff --git a/res/Fira/FiraSans-Bold.otf b/res/Fira/FiraSans-Bold.otf deleted file mode 100644 index 3e586b42..00000000 Binary files a/res/Fira/FiraSans-Bold.otf and /dev/null differ diff --git a/res/Fira/FiraSans-Light.otf b/res/Fira/FiraSans-Light.otf deleted file mode 100644 index 1445a4af..00000000 Binary files a/res/Fira/FiraSans-Light.otf and /dev/null differ diff --git a/res/Fira/FiraSans-Regular.otf b/res/Fira/FiraSans-Regular.otf deleted file mode 100644 index 98ef98c8..00000000 Binary files a/res/Fira/FiraSans-Regular.otf and /dev/null differ diff --git a/res/Fira/FiraSans-SemiBold.otf b/res/Fira/FiraSans-SemiBold.otf deleted file mode 100644 index 6f7204d8..00000000 Binary files a/res/Fira/FiraSans-SemiBold.otf and /dev/null differ diff --git a/res/Fira/SIL Open Font License.txt b/res/Fira/SIL Open Font License.txt deleted file mode 100644 index 8ad18250..00000000 --- a/res/Fira/SIL Open Font License.txt +++ /dev/null @@ -1,48 +0,0 @@ -Digitized data copyright (c) 2012-2015, The Mozilla Foundation and Telefonica S.A. -with Reserved Font Name < Fira >, - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. - -The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the copyright statement(s). - -"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. - -"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. - -5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/res/icons/close-menu-symbolic.svg b/res/icons/close-menu-symbolic.svg deleted file mode 100644 index caf00d31..00000000 --- a/res/icons/close-menu-symbolic.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/icons/open-menu-symbolic.svg b/res/icons/open-menu-symbolic.svg deleted file mode 100644 index efae2a2f..00000000 --- a/res/icons/open-menu-symbolic.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/noto/LICENSE b/res/noto/LICENSE new file mode 100644 index 00000000..d952d62c --- /dev/null +++ b/res/noto/LICENSE @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/res/noto/NotoSansMono-Bold.ttf b/res/noto/NotoSansMono-Bold.ttf new file mode 100644 index 00000000..47179fae Binary files /dev/null and b/res/noto/NotoSansMono-Bold.ttf differ diff --git a/res/noto/NotoSansMono-Regular.ttf b/res/noto/NotoSansMono-Regular.ttf new file mode 100644 index 00000000..dd3e6d00 Binary files /dev/null and b/res/noto/NotoSansMono-Regular.ttf differ diff --git a/res/open-sans/LICENSE b/res/open-sans/LICENSE new file mode 100644 index 00000000..c91bd228 --- /dev/null +++ b/res/open-sans/LICENSE @@ -0,0 +1,88 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/res/open-sans/OpenSans-Bold.ttf b/res/open-sans/OpenSans-Bold.ttf new file mode 100644 index 00000000..fd79d43b Binary files /dev/null and b/res/open-sans/OpenSans-Bold.ttf differ diff --git a/res/open-sans/OpenSans-ExtraBold.ttf b/res/open-sans/OpenSans-ExtraBold.ttf new file mode 100644 index 00000000..21f6f84a Binary files /dev/null and b/res/open-sans/OpenSans-ExtraBold.ttf differ diff --git a/res/open-sans/OpenSans-Light.ttf b/res/open-sans/OpenSans-Light.ttf new file mode 100644 index 00000000..0d381897 Binary files /dev/null and b/res/open-sans/OpenSans-Light.ttf differ diff --git a/res/open-sans/OpenSans-Regular.ttf b/res/open-sans/OpenSans-Regular.ttf new file mode 100644 index 00000000..db433349 Binary files /dev/null and b/res/open-sans/OpenSans-Regular.ttf differ diff --git a/res/open-sans/OpenSans-Semibold.ttf b/res/open-sans/OpenSans-Semibold.ttf new file mode 100644 index 00000000..1a7679e3 Binary files /dev/null and b/res/open-sans/OpenSans-Semibold.ttf differ diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 00000000..b7162896 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,40 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +#[cfg(feature = "winit")] +use crate::app; +#[cfg(feature = "single-instance")] +use crate::dbus_activation; + +pub const fn app(message: M) -> Action { + Action::App(message) +} +#[cfg(feature = "winit")] +pub const fn cosmic(message: app::Action) -> Action { + Action::Cosmic(message) +} + +pub const fn none() -> Action { + Action::None +} + +#[derive(Clone, Debug)] +#[must_use] +pub enum Action { + /// Messages from the application, for the application. + App(M), + #[cfg(feature = "winit")] + /// Internal messages to be handled by libcosmic. + Cosmic(app::Action), + #[cfg(feature = "single-instance")] + /// Dbus activation messages + DbusActivation(dbus_activation::Message), + /// Do nothing + None, +} + +impl From for Action { + fn from(value: M) -> Self { + Self::App(value) + } +} diff --git a/src/anim.rs b/src/anim.rs new file mode 100644 index 00000000..3186ff2e --- /dev/null +++ b/src/anim.rs @@ -0,0 +1,51 @@ +use std::time::{Duration, Instant}; + +/// A simple linear interpolation calculation function. +/// p = `percent_complete` in decimal form +#[must_use] +pub fn lerp(start: f32, end: f32, p: f32) -> f32 { + (1.0 - p) * start + p * end +} + +/// A fast smooth interpolation calculation function. +/// p = `percent_complete` in decimal form +#[must_use] +pub fn slerp(start: f32, end: f32, p: f32) -> f32 { + let t = smootherstep(p); + (1.0 - t) * start + t * end +} + +/// utility function which maps a value [0, 1] -> [0, 1] using the smootherstep function +pub fn smootherstep(t: f32) -> f32 { + (6.0 * t.powi(5) - 15.0 * t.powi(4) + 10.0 * t.powi(3)).clamp(0.0, 1.0) +} + +#[derive(Default, Debug)] +pub struct State { + pub last_change: Option, +} + +impl State { + pub fn changed(&mut self, dur: Duration) { + let t = self.t(dur, false); + let diff = dur.mul_f32(t.abs()); + let now = Instant::now(); + self.last_change = Some(now.checked_sub(diff).unwrap_or(now)); + } + + pub fn anim_done(&mut self, dur: Duration) { + if self + .last_change + .is_some_and(|t| Instant::now().duration_since(t) > dur) + { + self.last_change = None; + } + } + + pub fn t(&self, dur: Duration, forward: bool) -> f32 { + let res = self.last_change.map_or(1., |t| { + Instant::now().duration_since(t).as_millis() as f32 / dur.as_millis() as f32 + }); + if forward { res } else { 1. - res } + } +} diff --git a/src/app/action.rs b/src/app/action.rs new file mode 100644 index 00000000..fb982acb --- /dev/null +++ b/src/app/action.rs @@ -0,0 +1,79 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::surface; +use crate::theme::Theme; +use crate::widget::nav_bar; +use crate::{config::CosmicTk, keyboard_nav}; +#[cfg(all(feature = "wayland", target_os = "linux"))] +use cctk::sctk::reexports::csd_frame::{WindowManagerCapabilities, WindowState}; +use cosmic_theme::ThemeMode; + +/// A message managed internally by COSMIC. +#[derive(Clone, Debug)] +pub enum Action { + /// Activate the application + Activate(String), + /// Application requests theme change. + AppThemeChange(Theme), + /// Requests to close the window. + Close, + /// Closes or shows the context drawer. + ContextDrawer(bool), + #[cfg(feature = "single-instance")] + DbusConnection(zbus::Connection), + /// Requests to drag the window. + Drag, + /// Window focus changed + Focus(iced::window::Id), + /// Keyboard shortcuts managed by libcosmic. + KeyboardNav(keyboard_nav::Action), + /// Requests to maximize the window. + Maximize, + /// Requests to minimize the window. + Minimize, + /// Activates a navigation element from the nav bar. + NavBar(nav_bar::Id), + /// Activates a context menu for an item from the nav bar. + NavBarContext(nav_bar::Id), + /// A new window was opened. + Opened(iced::window::Id), + /// Set scaling factor + ScaleFactor(f32), + /// Show the window menu + ShowWindowMenu, + /// Tracks updates to window suggested size. + #[cfg(feature = "applet")] + SuggestedBounds(Option), + /// Internal surface message + Surface(surface::Action), + /// Notifies that a surface was closed. + /// Any data relating to the surface should be cleaned up. + SurfaceClosed(iced::window::Id), + /// Notification of system theme changes. + SystemThemeChange(Vec<&'static str>, Theme), + /// Notification of system theme mode changes. + SystemThemeModeChange(Vec<&'static str>, ThemeMode), + /// Toggles visibility of the nav bar. + ToggleNavBar, + /// Toggles the condensed status of the nav bar. + ToggleNavBarCondensed, + /// Toolkit configuration update + ToolkitConfig(CosmicTk), + /// Window focus lost + Unfocus(iced::window::Id), + /// Windowing system initialized + WindowingSystemInitialized, + /// Updates the window maximized state + WindowMaximized(iced::window::Id, bool), + /// Updates the tracked window geometry. + WindowResize(iced::window::Id, f32, f32), + /// Tracks updates to window state. + #[cfg(all(feature = "wayland", target_os = "linux"))] + WindowState(iced::window::Id, WindowState), + /// Capabilities the window manager supports + #[cfg(all(feature = "wayland", target_os = "linux"))] + WmCapabilities(iced::window::Id, WindowManagerCapabilities), + #[cfg(feature = "xdg-portal")] + DesktopSettings(crate::theme::portal::Desktop), +} diff --git a/src/app/command.rs b/src/app/command.rs deleted file mode 100644 index 74554c9c..00000000 --- a/src/app/command.rs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2023 System76 -// SPDX-License-Identifier: MPL-2.0 - -/// Asynchronous actions for COSMIC applications. -use super::Message; - -/// Commands for COSMIC applications. -pub type Command = iced::Command>; - -/// Creates a command which yields a [`crate::app::Message`]. -pub fn message(message: Message) -> Command { - crate::command::message(message) -} - -/// Convenience methods for building message-based commands. -pub mod message { - /// Creates a command which yields an application message. - pub fn app(message: M) -> crate::app::Command { - super::message(super::Message::App(message)) - } - - /// Creates a command which yields a cosmic message. - pub fn cosmic( - message: crate::app::cosmic::Message, - ) -> crate::app::Command { - super::message(super::Message::Cosmic(message)) - } -} - -pub fn drag() -> iced::Command> { - crate::command::drag().map(Message::Cosmic) -} - -pub fn fullscreen() -> iced::Command> { - crate::command::fullscreen().map(Message::Cosmic) -} - -pub fn minimize() -> iced::Command> { - crate::command::minimize().map(Message::Cosmic) -} - -pub fn set_scaling_factor(factor: f32) -> iced::Command> { - message::cosmic(super::cosmic::Message::ScaleFactor(factor)) -} - -pub fn set_theme(theme: crate::Theme) -> iced::Command> { - message::cosmic(super::cosmic::Message::AppThemeChange(theme)) -} - -pub fn set_title(title: String) -> iced::Command> { - crate::command::set_title(title).map(Message::Cosmic) -} - -pub fn set_windowed() -> iced::Command> { - crate::command::set_windowed().map(Message::Cosmic) -} - -pub fn toggle_fullscreen() -> iced::Command> { - crate::command::toggle_fullscreen().map(Message::Cosmic) -} diff --git a/src/app/context_drawer.rs b/src/app/context_drawer.rs new file mode 100644 index 00000000..ac9d5673 --- /dev/null +++ b/src/app/context_drawer.rs @@ -0,0 +1,78 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 +// +use std::borrow::Cow; + +use crate::Element; + +pub struct ContextDrawer<'a, Message: Clone + 'static> { + pub title: Option>, + pub actions: Option>, + pub header: Option>, + pub content: Element<'a, Message>, + pub footer: Option>, + pub on_close: Message, +} + +#[cfg(feature = "about")] +pub fn about<'a, Message: Clone + 'static>( + about: &'a crate::widget::about::About, + on_url_press: impl Fn(&'a str) -> Message + 'a, + on_close: Message, +) -> ContextDrawer<'a, Message> { + context_drawer(crate::widget::about(about, on_url_press), on_close) +} + +pub fn context_drawer<'a, Message: Clone + 'static>( + content: impl Into>, + on_close: Message, +) -> ContextDrawer<'a, Message> { + ContextDrawer { + title: None, + actions: None, + header: None, + content: content.into(), + footer: None, + on_close, + } +} + +impl<'a, Message: Clone + 'static> ContextDrawer<'a, Message> { + /// Set a context drawer title + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// App-specific actions at the top-left corner of the context drawer + pub fn actions(mut self, actions: impl Into>) -> Self { + self.actions = Some(actions.into()); + self + } + + /// Elements placed above the context drawer scrollable + pub fn header(mut self, header: impl Into>) -> Self { + self.header = Some(header.into()); + self + } + + /// Elements placed below the context drawer scrollable + pub fn footer(mut self, footer: impl Into>) -> Self { + self.footer = Some(footer.into()); + self + } + + pub fn map( + self, + on_message: fn(Message) -> Out, + ) -> ContextDrawer<'a, Out> { + ContextDrawer { + title: self.title, + actions: self.actions.map(|el| el.map(on_message)), + header: self.header.map(|el| el.map(on_message)), + content: self.content.map(on_message), + footer: self.footer.map(|el| el.map(on_message)), + on_close: on_message(self.on_close), + } + } +} diff --git a/src/app/core.rs b/src/app/core.rs deleted file mode 100644 index d401f014..00000000 --- a/src/app/core.rs +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright 2023 System76 -// SPDX-License-Identifier: MPL-2.0 - -use cosmic_config::CosmicConfigEntry; -use cosmic_theme::ThemeMode; - -use crate::Theme; - -/// Status of the nav bar and its panels. -#[derive(Clone)] -pub struct NavBar { - active: bool, - toggled: bool, - toggled_condensed: bool, -} - -/// COSMIC-specific settings for windows. -#[allow(clippy::struct_excessive_bools)] -#[derive(Clone)] -pub struct Window { - /// Label to display as context drawer title. - pub context_title: String, - /// Label to display as header bar title. - pub header_title: String, - pub use_template: bool, - pub can_fullscreen: bool, - pub sharp_corners: bool, - pub show_context: bool, - pub show_headerbar: bool, - pub show_window_menu: bool, - pub show_maximize: bool, - pub show_minimize: bool, - height: u32, - width: u32, -} - -/// COSMIC-specific application settings -#[derive(Clone)] -pub struct Core { - /// Enables debug features in cosmic/iced. - pub debug: bool, - - /// Whether the window is too small for the nav bar + main content. - is_condensed: bool, - - /// Current status of the nav bar panel. - nav_bar: NavBar, - - /// Scaling factor used by the application - scale_factor: f32, - - pub(super) theme_sub_counter: u64, - /// Last known system theme - pub(super) system_theme: Theme, - - /// Theme mode - pub(super) system_theme_mode: ThemeMode, - - pub(super) title: String, - - pub window: Window, - - #[cfg(feature = "applet")] - pub applet: crate::applet::Context, - - #[cfg(feature = "single-instance")] - pub(crate) single_instance: bool, -} - -impl Default for Core { - fn default() -> Self { - Self { - debug: false, - is_condensed: false, - nav_bar: NavBar { - active: true, - toggled: true, - toggled_condensed: true, - }, - scale_factor: 1.0, - title: String::new(), - theme_sub_counter: 0, - system_theme: crate::theme::active(), - system_theme_mode: ThemeMode::config() - .map(|c| { - ThemeMode::get_entry(&c).unwrap_or_else(|(errors, mode)| { - for e in errors { - tracing::error!("{e}"); - } - mode - }) - }) - .unwrap_or_default(), - window: Window { - context_title: String::new(), - header_title: String::new(), - use_template: true, - can_fullscreen: false, - sharp_corners: false, - show_context: false, - show_headerbar: true, - show_maximize: true, - show_minimize: true, - show_window_menu: false, - height: 0, - width: 0, - }, - #[cfg(feature = "applet")] - applet: crate::applet::Context::default(), - #[cfg(feature = "single-instance")] - single_instance: false, - } - } -} - -impl Core { - /// Whether the window is too small for the nav bar + main content. - #[must_use] - pub fn is_condensed(&self) -> bool { - self.is_condensed - } - - /// The scaling factor used by the application. - #[must_use] - pub fn scale_factor(&self) -> f32 { - self.scale_factor - } - - /// Changes the scaling factor used by the application. - pub(crate) fn set_scale_factor(&mut self, factor: f32) { - self.scale_factor = factor; - self.is_condensed_update(); - } - - /// Set context drawer header title - pub fn set_context_title(&mut self, title: String) { - self.window.context_title = title; - } - - /// Set header bar title - pub fn set_header_title(&mut self, title: String) { - self.window.header_title = title; - } - - /// Whether to show or hide the main window's content. - pub(crate) fn show_content(&self) -> bool { - !self.is_condensed || !self.nav_bar.toggled_condensed - } - - /// Call this whenever the scaling factor or window width has changed. - #[allow(clippy::cast_precision_loss)] - fn is_condensed_update(&mut self) { - self.is_condensed = (600.0 * self.scale_factor) > self.window.width as f32; - self.nav_bar_update(); - } - - /// Whether the nav panel is visible or not - #[must_use] - pub fn nav_bar_active(&self) -> bool { - self.nav_bar.active - } - - pub fn nav_bar_toggle(&mut self) { - self.nav_bar.toggled = !self.nav_bar.toggled; - self.nav_bar_set_toggled_condensed(self.nav_bar.toggled); - } - - pub fn nav_bar_toggle_condensed(&mut self) { - self.nav_bar_set_toggled_condensed(!self.nav_bar.toggled_condensed); - } - - pub(crate) fn nav_bar_set_toggled_condensed(&mut self, toggled: bool) { - self.nav_bar.toggled_condensed = toggled; - self.nav_bar_update(); - } - - pub(crate) fn nav_bar_update(&mut self) { - self.nav_bar.active = if self.is_condensed { - self.nav_bar.toggled_condensed - } else { - self.nav_bar.toggled - }; - } - - /// Set the height of the main window. - pub(crate) fn set_window_height(&mut self, new_height: u32) { - self.window.height = new_height; - } - - /// Set the width of the main window. - pub(crate) fn set_window_width(&mut self, new_width: u32) { - self.window.width = new_width; - self.is_condensed_update(); - } - - /// Get the current system theme - pub fn system_theme(&self) -> &Theme { - &self.system_theme - } - - #[must_use] - /// Get the current system theme mode - pub fn system_theme_mode(&self) -> ThemeMode { - self.system_theme_mode - } -} diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index f621c792..030ed041 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -1,246 +1,665 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use super::{command, Application, ApplicationExt, Core, Subscription}; -use crate::theme::{self, Theme, ThemeType, THEME}; -use crate::widget::nav_bar; -use crate::{keyboard_nav, Element}; -#[cfg(feature = "wayland")] +use std::borrow::Borrow; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use super::{Action, Application, ApplicationExt, Subscription}; +use crate::theme::{THEME, Theme, ThemeType}; +use crate::{Core, Element, keyboard_nav}; +#[cfg(all(feature = "wayland", target_os = "linux"))] use cctk::sctk::reexports::csd_frame::{WindowManagerCapabilities, WindowState}; use cosmic_theme::ThemeMode; -#[cfg(feature = "wayland")] -use iced::event::wayland::{self, WindowEvent}; -#[cfg(feature = "wayland")] -use iced::event::PlatformSpecific; -use iced::window; -#[cfg(not(feature = "wayland"))] -use iced_runtime::command::Action; -#[cfg(not(feature = "wayland"))] -use iced_runtime::window::Action as WindowAction; +#[cfg(not(any(feature = "multi-window", feature = "wayland", target_os = "linux")))] +use iced::Application as IcedApplication; +#[cfg(all(feature = "wayland", target_os = "linux"))] +use iced::event::wayland; +use iced::{Task, theme, window}; +use iced_futures::event::listen_with; +#[cfg(all(feature = "wayland", target_os = "linux"))] +use iced_winit::SurfaceIdWrapper; +use palette::color_difference::EuclideanDistance; -/// A message managed internally by COSMIC. -#[derive(Clone, Debug)] -pub enum Message { - /// Application requests theme change. - AppThemeChange(Theme), - /// Requests to close the window. - Close, - /// Closes or shows the context drawer. - ContextDrawer(bool), - /// Requests to drag the window. - Drag, - /// Keyboard shortcuts managed by libcosmic. - KeyboardNav(keyboard_nav::Message), - /// Requests to maximize the window. - Maximize, - /// Requests to minimize the window. - Minimize, - /// Activates a navigation element from the nav bar. - NavBar(nav_bar::Id), - /// Set scaling factor - ScaleFactor(f32), - /// Toggles visibility of the nav bar. - ToggleNavBar, - /// Toggles the condensed status of the nav bar. - ToggleNavBarCondensed, - /// Notification of system theme changes. - SystemThemeChange(Theme), - /// Notification of system theme mode changes. - SystemThemeModeChange(ThemeMode), - /// Updates the tracked window geometry. - WindowResize(window::Id, u32, u32), - /// Tracks updates to window state. - #[cfg(feature = "wayland")] - WindowState(window::Id, WindowState), - /// Capabilities the window manager supports - #[cfg(feature = "wayland")] - WmCapabilities(window::Id, WindowManagerCapabilities), - /// Activate the application - Activate(String), +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +#[non_exhaustive] +pub enum WindowingSystem { + UiKit, + AppKit, + Orbital, + OhosNdk, + Xlib, + Xcb, + Wayland, + Drm, + Gbm, + Win32, + WinRt, + Web, + WebCanvas, + WebOffscreenCanvas, + AndroidNdk, + Haiku, +} + +pub(crate) static WINDOWING_SYSTEM: std::sync::OnceLock = + std::sync::OnceLock::new(); + +pub fn windowing_system() -> Option { + WINDOWING_SYSTEM.get().copied() +} + +fn init_windowing_system(handle: window::raw_window_handle::WindowHandle) -> crate::Action { + let raw = handle.as_ref(); + let system = match raw { + window::raw_window_handle::RawWindowHandle::UiKit(_) => WindowingSystem::UiKit, + window::raw_window_handle::RawWindowHandle::AppKit(_) => WindowingSystem::AppKit, + window::raw_window_handle::RawWindowHandle::Orbital(_) => WindowingSystem::Orbital, + window::raw_window_handle::RawWindowHandle::OhosNdk(_) => WindowingSystem::OhosNdk, + window::raw_window_handle::RawWindowHandle::Xlib(_) => WindowingSystem::Xlib, + window::raw_window_handle::RawWindowHandle::Xcb(_) => WindowingSystem::Xcb, + window::raw_window_handle::RawWindowHandle::Wayland(_) => WindowingSystem::Wayland, + window::raw_window_handle::RawWindowHandle::Web(_) => WindowingSystem::Web, + window::raw_window_handle::RawWindowHandle::WebCanvas(_) => WindowingSystem::WebCanvas, + window::raw_window_handle::RawWindowHandle::WebOffscreenCanvas(_) => { + WindowingSystem::WebOffscreenCanvas + } + window::raw_window_handle::RawWindowHandle::AndroidNdk(_) => WindowingSystem::AndroidNdk, + window::raw_window_handle::RawWindowHandle::Haiku(_) => WindowingSystem::Haiku, + window::raw_window_handle::RawWindowHandle::Drm(_) => WindowingSystem::Drm, + window::raw_window_handle::RawWindowHandle::Gbm(_) => WindowingSystem::Gbm, + window::raw_window_handle::RawWindowHandle::Win32(_) => WindowingSystem::Win32, + window::raw_window_handle::RawWindowHandle::WinRt(_) => WindowingSystem::WinRt, + _ => { + tracing::warn!("Unknown windowing system: {raw:?}"); + return crate::Action::Cosmic(Action::WindowingSystemInitialized); + } + }; + + _ = WINDOWING_SYSTEM.set(system); + crate::Action::Cosmic(Action::WindowingSystemInitialized) } #[derive(Default)] -pub(crate) struct Cosmic { - pub(crate) app: App, - #[cfg(feature = "wayland")] - pub(crate) should_exit: bool, +pub struct Cosmic { + pub app: App, + #[cfg(all(feature = "wayland", target_os = "linux"))] + pub surface_views: HashMap< + window::Id, + ( + Option, + SurfaceIdWrapper, + Box Fn(&'a App) -> Element<'a, crate::Action>>, + ), + >, + pub tracked_windows: HashSet, + pub opened_surfaces: HashMap, } -impl iced::Application for Cosmic +impl Cosmic where T::Message: Send + 'static, { - type Executor = T::Executor; - type Flags = (Core, T::Flags); - type Message = super::Message; - type Theme = Theme; + pub fn init( + (mut core, flags): (Core, T::Flags), + ) -> (Self, iced::Task>) { + #[cfg(all(feature = "dbus-config", target_os = "linux"))] + { + use iced_futures::futures::executor::block_on; + core.settings_daemon = block_on(cosmic_config::dbus::settings_daemon_proxy()).ok(); + } + let id = core.main_window_id().unwrap_or(window::Id::RESERVED); - fn new((core, flags): Self::Flags) -> (Self, iced::Command) { let (model, command) = T::init(core, flags); - (Self::new(model), command) + ( + Self::new(model), + Task::batch([ + command, + iced_runtime::window::run_with_handle(id, init_windowing_system), + ]), + ) } - #[cfg(feature = "wayland")] - fn close_requested(&self, id: window::Id) -> Self::Message { - self.app - .on_close_requested(id) - .map_or(super::Message::None, super::Message::App) - } - - fn title(&self) -> String { + #[cfg(not(feature = "multi-window"))] + pub fn title(&self) -> String { self.app.title().to_string() } - fn update(&mut self, message: Self::Message) -> iced::Command { - match message { - super::Message::App(message) => self.app.update(message), - super::Message::Cosmic(message) => self.cosmic_update(message), - super::Message::None => iced::Command::none(), - #[cfg(feature = "single-instance")] - super::Message::DbusActivation(message) => self.app.dbus_activation(message), - } + #[cfg(feature = "multi-window")] + pub fn title(&self, id: window::Id) -> String { + self.app.title(id).to_string() } - fn scale_factor(&self) -> f64 { + #[allow(clippy::too_many_lines)] + pub fn surface_update( + &mut self, + _surface_message: crate::surface::Action, + ) -> iced::Task> { + #[cfg(feature = "surface-message")] + match _surface_message { + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::AppSubsurface(settings, view) => { + let Some(settings) = std::sync::Arc::try_unwrap(settings) + .ok() + .and_then(|s| s.downcast:: iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + Send + Sync>>().ok()) else { + tracing::error!("Invalid settings for subsurface"); + return Task::none(); + }; + + if let Some(view) = view.and_then(|view| { + match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Fn(&'a T) -> Element<'a, crate::Action> + + Send + + Sync, + >>() { + Ok(v) => Some(v), + Err(err) => { + tracing::error!("Invalid view for subsurface view: {err:?}"); + + None + } + } + }) { + let settings = settings(&mut self.app); + + self.get_subsurface(settings, *view) + } else { + iced_winit::commands::subsurface::get_subsurface(settings(&mut self.app)) + } + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::Subsurface(settings, view) => { + let Some(settings) = std::sync::Arc::try_unwrap(settings) + .ok() + .and_then(|s| s.downcast:: iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + Send + Sync>>().ok()) else { + tracing::error!("Invalid settings for subsurface"); + return Task::none(); + }; + + if let Some(view) = view.and_then(|view| { + match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Element<'static, crate::Action> + Send + Sync, + >>() { + Ok(v) => Some(v), + Err(err) => { + tracing::error!("Invalid view for subsurface view: {err:?}"); + + None + } + } + }) { + let settings = settings(); + + self.get_subsurface(settings, Box::new(move |_| view())) + } else { + iced_winit::commands::subsurface::get_subsurface(settings()) + } + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::AppPopup(settings, view) => { + let Some(settings) = std::sync::Arc::try_unwrap(settings) + .ok() + .and_then(|s| s.downcast:: iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + Send + Sync>>().ok()) else { + tracing::error!("Invalid settings for popup"); + return Task::none(); + }; + + if let Some(view) = view.and_then(|view| { + match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Fn(&'a T) -> Element<'a, crate::Action> + + Send + + Sync, + >>() { + Ok(v) => Some(v), + Err(err) => { + tracing::error!("Invalid view for subsurface view: {err:?}"); + None + } + } + }) { + let settings = settings(&mut self.app); + + self.get_popup(settings, *view) + } else { + iced_winit::commands::popup::get_popup(settings(&mut self.app)) + } + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::DestroyPopup(id) => { + iced_winit::commands::popup::destroy_popup(id) + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::DestroyTooltipPopup => { + #[cfg(feature = "applet")] + { + iced_winit::commands::popup::destroy_popup(*crate::applet::TOOLTIP_WINDOW_ID) + } + #[cfg(not(feature = "applet"))] + { + Task::none() + } + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::DestroySubsurface(id) => { + iced_winit::commands::subsurface::destroy_subsurface(id) + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::DestroyWindow(id) => iced::window::close(id), + crate::surface::Action::ResponsiveMenuBar { + menu_bar, + limits, + size, + } => { + let core = self.app.core_mut(); + core.menu_bars.insert(menu_bar, (limits, size)); + iced::Task::none() + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::Popup(settings, view) => { + let Some(settings) = std::sync::Arc::try_unwrap(settings) + .ok() + .and_then(|s| s.downcast:: iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + Send + Sync>>().ok()) else { + tracing::error!("Invalid settings for popup"); + return Task::none(); + }; + + if let Some(view) = view.and_then(|view| { + match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Element<'static, crate::Action> + Send + Sync, + >>() { + Ok(v) => Some(v), + Err(err) => { + tracing::error!("Invalid view for subsurface view: {err:?}"); + None + } + } + }) { + let settings = settings(); + + self.get_popup(settings, Box::new(move |_| view())) + } else { + iced_winit::commands::popup::get_popup(settings()) + } + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::AppWindow(id, settings, view) => { + let Some(settings) = std::sync::Arc::try_unwrap(settings).ok().and_then(|s| { + s.downcast:: iced::window::Settings + Send + Sync>>() + .ok() + }) else { + tracing::error!("Invalid settings for AppWindow"); + return Task::none(); + }; + + if let Some(view) = view.and_then(|view| { + match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Fn(&'a T) -> Element<'a, crate::Action> + + Send + + Sync, + >>() { + Ok(v) => Some(v), + Err(err) => { + tracing::error!("Invalid view for AppWindow: {err:?}"); + None + } + } + }) { + let settings = settings(&mut self.app); + self.tracked_windows.insert(id); + + self.get_window(id, settings, *view) + } else { + let settings = settings(&mut self.app); + + self.tracked_windows.insert(id); + iced_runtime::task::oneshot(|channel| { + iced_runtime::Action::Window(iced_runtime::window::Action::Open( + id, settings, channel, + )) + }) + .discard() + } + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + crate::surface::Action::Window(id, settings, view) => { + let Some(settings) = std::sync::Arc::try_unwrap(settings).ok().and_then(|s| { + s.downcast:: iced::window::Settings + Send + Sync>>() + .ok() + }) else { + tracing::error!("Invalid settings for Window"); + return Task::none(); + }; + + if let Some(view) = view.and_then(|view| { + match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Element<'static, crate::Action> + Send + Sync, + >>() { + Ok(v) => Some(v), + Err(err) => { + tracing::error!("Invalid view for Window: {err:?}"); + None + } + } + }) { + let settings = settings(); + self.tracked_windows.insert(id); + + self.get_window(id, settings, Box::new(move |_| view())) + } else { + let settings = settings(); + + self.tracked_windows.insert(id); + + iced_runtime::task::oneshot(|channel| { + iced_runtime::Action::Window(iced_runtime::window::Action::Open( + id, settings, channel, + )) + }) + .discard() + } + } + + crate::surface::Action::Ignore => iced::Task::none(), + crate::surface::Action::Task(f) => { + f().map(|sm| crate::Action::Cosmic(Action::Surface(sm))) + } + _ => iced::Task::none(), + } + + #[cfg(not(feature = "surface-message"))] + iced::Task::none() + } + + pub fn update( + &mut self, + message: crate::Action, + ) -> iced::Task> { + let message = match message { + crate::Action::App(message) => self.app.update(message), + crate::Action::Cosmic(message) => self.cosmic_update(message), + crate::Action::None => iced::Task::none(), + #[cfg(feature = "single-instance")] + crate::Action::DbusActivation(message) => { + let mut task = self.app.dbus_activation(message); + + if let Some(id) = self.app.core().main_window_id() { + let unminimize = iced_runtime::window::minimize::<()>(id, false); + task = task.chain(unminimize.discard()); + } + + task + } + }; + + #[cfg(all(target_env = "gnu", not(target_os = "windows")))] + crate::malloc::trim(0); + + message + } + + #[cfg(not(feature = "multi-window"))] + pub fn scale_factor(&self) -> f64 { f64::from(self.app.core().scale_factor()) } - #[cfg(feature = "wayland")] - fn should_exit(&self) -> bool { - self.should_exit || self.app.should_exit() + #[cfg(feature = "multi-window")] + pub fn scale_factor(&self, _id: window::Id) -> f64 { + f64::from(self.app.core().scale_factor()) } - fn style(&self) -> ::Style { + pub fn style(&self, theme: &Theme) -> theme::Style { if let Some(style) = self.app.style() { style - } else if self.app.core().window.sharp_corners { - theme::Application::default() + } else if self.app.core().window.is_maximized { + let theme = THEME.lock().unwrap(); + crate::style::iced::application::style(theme.borrow()) } else { - theme::Application::Custom(Box::new(|theme| iced_style::application::Appearance { + let theme = THEME.lock().unwrap(); + + theme::Style { background_color: iced_core::Color::TRANSPARENT, icon_color: theme.cosmic().on_bg_color().into(), text_color: theme.cosmic().on_bg_color().into(), - })) + } } } - fn subscription(&self) -> Subscription { - let window_events = iced::subscription::events_with(|event, _| { + #[allow(clippy::too_many_lines)] + #[cold] + pub fn subscription(&self) -> Subscription> { + let window_events = listen_with(|event, _, id| { match event { - iced::Event::Window(id, window::Event::Resized { width, height }) => { - return Some(Message::WindowResize(id, width, height)); + iced::Event::Window(window::Event::Resized(iced::Size { width, height })) => { + return Some(Action::WindowResize(id, width, height)); } - - #[cfg(feature = "wayland")] - iced::Event::PlatformSpecific(PlatformSpecific::Wayland(event)) => match event { - wayland::Event::Window(WindowEvent::State(state), _surface, id) => { - return Some(Message::WindowState(id, state)); + iced::Event::Window(window::Event::Opened { .. }) => { + return Some(Action::Opened(id)); + } + iced::Event::Window(window::Event::Closed) => { + return Some(Action::SurfaceClosed(id)); + } + iced::Event::Window(window::Event::Focused) => return Some(Action::Focus(id)), + iced::Event::Window(window::Event::Unfocused) => return Some(Action::Unfocus(id)), + #[cfg(all(feature = "wayland", target_os = "linux"))] + iced::Event::PlatformSpecific(iced::event::PlatformSpecific::Wayland(event)) => { + match event { + wayland::Event::Popup(wayland::PopupEvent::Done, _, id) + | wayland::Event::Layer(wayland::LayerEvent::Done, _, id) => { + return Some(Action::SurfaceClosed(id)); + } + #[cfg(feature = "applet")] + wayland::Event::Window( + iced::event::wayland::WindowEvent::SuggestedBounds(b), + ) => { + return Some(Action::SuggestedBounds(b)); + } + #[cfg(all(feature = "wayland", target_os = "linux"))] + wayland::Event::Window(iced::event::wayland::WindowEvent::WindowState( + s, + )) => { + return Some(Action::WindowState(id, s)); + } + _ => (), } - - wayland::Event::Window( - WindowEvent::WmCapabilities(capabilities), - _surface, - id, - ) => { - return Some(Message::WmCapabilities(id, capabilities)); - } - - _ => (), - }, + } _ => (), } None }); - Subscription::batch(vec![ - self.app.subscription().map(super::Message::App), - keyboard_nav::subscription() - .map(Message::KeyboardNav) - .map(super::Message::Cosmic), - theme::subscription( - self.app.core().theme_sub_counter, - self.app.core().system_theme_mode.is_dark, - ) - .map(Message::SystemThemeChange) - .map(super::Message::Cosmic), - cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>( - 0, - cosmic_theme::THEME_MODE_ID.into(), - cosmic_theme::ThemeMode::version(), - ) - .map(|(_, u)| match u { - Ok(t) => Message::SystemThemeModeChange(t), - Err((errors, t)) => { - for e in errors { - tracing::error!("{e}"); - } - Message::SystemThemeModeChange(t) - } - }) - .map(super::Message::Cosmic), - window_events.map(super::Message::Cosmic), - #[cfg(feature = "single-instance")] + let mut subscriptions = vec![ + self.app.subscription().map(crate::Action::App), self.app .core() - .single_instance - .then(|| super::single_instance_subscription::()) - .unwrap_or_else(|| Subscription::none()), - ]) + .watch_config::(crate::config::ID) + .map(|update| { + for why in update + .errors + .into_iter() + .filter(cosmic_config::Error::is_err) + { + if let cosmic_config::Error::GetKey(_, err) = &why { + if err.kind() == std::io::ErrorKind::NotFound { + // No system default config installed; don't error + continue; + } + } + tracing::error!(?why, "cosmic toolkit config update error"); + } + + crate::Action::Cosmic(Action::ToolkitConfig(update.config)) + }), + self.app + .core() + .watch_config::( + if if let ThemeType::System { prefer_dark, .. } = + THEME.lock().unwrap().theme_type + { + prefer_dark + } else { + None + } + .unwrap_or_else(|| self.app.core().system_theme_mode.is_dark) + { + cosmic_theme::DARK_THEME_ID + } else { + cosmic_theme::LIGHT_THEME_ID + }, + ) + .map(|update| { + for why in update + .errors + .into_iter() + .filter(cosmic_config::Error::is_err) + { + tracing::error!(?why, "cosmic theme config update error"); + } + Action::SystemThemeChange( + update.keys, + crate::theme::Theme::system(Arc::new(update.config)), + ) + }) + .map(crate::Action::Cosmic), + self.app + .core() + .watch_config::(cosmic_theme::THEME_MODE_ID) + .map(|update| { + for error in update + .errors + .into_iter() + .filter(cosmic_config::Error::is_err) + { + tracing::error!(?error, "error reading system theme mode update"); + } + Action::SystemThemeModeChange(update.keys, update.config) + }) + .map(crate::Action::Cosmic), + window_events.map(crate::Action::Cosmic), + #[cfg(feature = "xdg-portal")] + crate::theme::portal::desktop_settings() + .map(Action::DesktopSettings) + .map(crate::Action::Cosmic), + ]; + + if self.app.core().keyboard_nav { + subscriptions.push( + keyboard_nav::subscription() + .map(Action::KeyboardNav) + .map(crate::Action::Cosmic), + ); + } + + #[cfg(feature = "single-instance")] + if self.app.core().single_instance { + subscriptions.push(crate::dbus_activation::subscription::()); + } + + Subscription::batch(subscriptions) } - fn theme(&self) -> Self::Theme { + #[cfg(not(feature = "multi-window"))] + pub fn theme(&self) -> Theme { crate::theme::active() } - #[cfg(feature = "wayland")] - fn view(&self, id: window::Id) -> Element { - if id != window::Id(0) { - return self.app.view_window(id).map(super::Message::App); - } - - if self.app.core().window.use_template { - self.app.view_main() - } else { - self.app.view().map(super::Message::App) - } + #[cfg(feature = "multi-window")] + pub fn theme(&self, _id: window::Id) -> Theme { + crate::theme::active() } - #[cfg(not(feature = "wayland"))] - fn view(&self) -> Element { - self.app.view_main() + #[cfg(feature = "multi-window")] + pub fn view(&self, id: window::Id) -> Element<'_, crate::Action> { + #[cfg(all(feature = "wayland", target_os = "linux"))] + if let Some((_, _, v)) = self.surface_views.get(&id) { + return v(&self.app); + } + if self + .app + .core() + .main_window_id() + .is_none_or(|main_id| main_id != id) + { + return self.app.view_window(id).map(crate::Action::App); + } + + let view = if self.app.core().window.use_template { + self.app.view_main() + } else { + self.app.view().map(crate::Action::App) + }; + + #[cfg(all(target_env = "gnu", not(target_os = "windows")))] + crate::malloc::trim(0); + + view + } + + #[cfg(not(feature = "multi-window"))] + pub fn view(&self) -> Element> { + let view = self.app.view_main(); + + #[cfg(all(target_env = "gnu", not(target_os = "windows")))] + crate::malloc::trim(0); + + view } } impl Cosmic { - #[cfg(feature = "wayland")] - pub fn close(&mut self) -> iced::Command> { - self.should_exit = true; - iced::Command::none() - } - - #[cfg(not(feature = "wayland"))] #[allow(clippy::unused_self)] - pub fn close(&mut self) -> iced::Command> { - iced::Command::single(Action::Window(WindowAction::Close)) + #[cold] + pub fn close(&mut self) -> iced::Task> { + if let Some(id) = self.app.core().main_window_id() { + iced::window::close(id) + } else { + iced::Task::none() + } } #[allow(clippy::too_many_lines)] - fn cosmic_update(&mut self, message: Message) -> iced::Command> { + fn cosmic_update(&mut self, message: Action) -> iced::Task> { match message { - Message::WindowResize(id, width, height) => { - if window::Id(0) == id { + Action::WindowMaximized(id, maximized) => { + #[cfg(not(all(feature = "wayland", target_os = "linux")))] + if self + .app + .core() + .main_window_id() + .is_some_and(|main_id| main_id == id) + { + self.app.core_mut().window.sharp_corners = maximized; + } + } + + Action::WindowResize(id, width, height) => { + if self + .app + .core() + .main_window_id() + .is_some_and(|main_id| main_id == id) + { self.app.core_mut().set_window_width(width); self.app.core_mut().set_window_height(height); } self.app.on_window_resize(id, width, height); + + //TODO: more efficient test of maximized (winit has no event for maximize if set by the OS) + return iced::window::is_maximized(id).map(move |maximized| { + crate::Action::Cosmic(Action::WindowMaximized(id, maximized)) + }); } - #[cfg(feature = "wayland")] - Message::WindowState(id, state) => { - if window::Id(0) == id { + #[cfg(all(feature = "wayland", target_os = "linux"))] + Action::WindowState(id, state) => { + if self + .app + .core() + .main_window_id() + .is_some_and(|main_id| main_id == id) + { self.app.core_mut().window.sharp_corners = state.intersects( WindowState::MAXIMIZED | WindowState::FULLSCREEN @@ -250,14 +669,49 @@ impl Cosmic { | WindowState::TILED_TOP | WindowState::TILED_BOTTOM, ); + self.app.core_mut().window.is_maximized = + state.intersects(WindowState::MAXIMIZED | WindowState::FULLSCREEN); + } + if self.app.core().sync_window_border_radii_to_theme() { + use iced_runtime::platform_specific::wayland::CornerRadius; + use iced_winit::platform_specific::commands::corner_radius::corner_radius; + + let theme = THEME.lock().unwrap(); + let t = theme.cosmic(); + let radii = t.radius_s().map(|x| if x < 4.0 { x } else { x + 4.0 }); + let cur_rad = CornerRadius { + top_left: radii[0].round() as u32, + top_right: radii[1].round() as u32, + bottom_right: radii[2].round() as u32, + bottom_left: radii[3].round() as u32, + }; + let rounded = !self.app.core().window.sharp_corners; + return Task::batch([corner_radius( + id, + if rounded { + Some(cur_rad) + } else { + let rad_0 = t.radius_0(); + Some(CornerRadius { + top_left: rad_0[0].round() as u32, + top_right: rad_0[1].round() as u32, + bottom_right: rad_0[2].round() as u32, + bottom_left: rad_0[3].round() as u32, + }) + }, + ) + .discard()]); } } - #[cfg(feature = "wayland")] - Message::WmCapabilities(id, capabilities) => { - if window::Id(0) == id { - self.app.core_mut().window.can_fullscreen = - capabilities.contains(WindowManagerCapabilities::FULLSCREEN); + #[cfg(all(feature = "wayland", target_os = "linux"))] + Action::WmCapabilities(id, capabilities) => { + if self + .app + .core() + .main_window_id() + .is_some_and(|main_id| main_id == id) + { self.app.core_mut().window.show_maximize = capabilities.contains(WindowManagerCapabilities::MAXIMIZE); self.app.core_mut().window.show_minimize = @@ -267,116 +721,574 @@ impl Cosmic { } } - Message::KeyboardNav(message) => match message { - keyboard_nav::Message::Unfocus => { - return keyboard_nav::unfocus().map(super::Message::Cosmic) + Action::KeyboardNav(message) => match message { + keyboard_nav::Action::FocusNext => { + return iced::widget::operation::focus_next().map(crate::Action::Cosmic); } - keyboard_nav::Message::FocusNext => { - return iced::widget::focus_next().map(super::Message::Cosmic) + keyboard_nav::Action::FocusPrevious => { + return iced::widget::operation::focus_previous().map(crate::Action::Cosmic); } - keyboard_nav::Message::FocusPrevious => { - return iced::widget::focus_previous().map(super::Message::Cosmic) - } - keyboard_nav::Message::Escape => return self.app.on_escape(), - keyboard_nav::Message::Search => return self.app.on_search(), + keyboard_nav::Action::Escape => return self.app.on_escape(), + keyboard_nav::Action::Search => return self.app.on_search(), - keyboard_nav::Message::Fullscreen => return command::toggle_fullscreen(), + keyboard_nav::Action::Fullscreen => return self.app.core().toggle_maximize(None), }, - Message::ContextDrawer(show) => { - self.app.core_mut().window.show_context = show; + Action::ContextDrawer(show) => { + self.app.core_mut().set_show_context(show); + return self.app.on_context_drawer(); } - Message::Drag => return command::drag(), + Action::Drag => return self.app.core().drag(None), - Message::Minimize => return command::minimize(), + Action::Minimize => return self.app.core().minimize(None), - Message::Maximize => { - if self.app.core().window.sharp_corners { - self.app.core_mut().window.sharp_corners = false; - return command::set_windowed(); - } + Action::Maximize => return self.app.core().toggle_maximize(None), - self.app.core_mut().window.sharp_corners = true; - return command::fullscreen(); - } - - Message::NavBar(key) => { + Action::NavBar(key) => { self.app.core_mut().nav_bar_set_toggled_condensed(false); return self.app.on_nav_select(key); } - Message::ToggleNavBar => { + Action::NavBarContext(key) => { + self.app.core_mut().nav_bar_set_context(key); + return self.app.on_nav_context(key); + } + + Action::ToggleNavBar => { self.app.core_mut().nav_bar_toggle(); } - Message::ToggleNavBarCondensed => { + Action::ToggleNavBarCondensed => { self.app.core_mut().nav_bar_toggle_condensed(); } - Message::AppThemeChange(mut theme) => { - // Apply last-known system theme if the system theme is preferred. - if let ThemeType::System(_) = theme.theme_type { + Action::AppThemeChange(mut theme) => { + if let ThemeType::System { theme: _, .. } = theme.theme_type { self.app.core_mut().theme_sub_counter += 1; - theme = self.app.core().system_theme.clone(); + + let portal_accent = self.app.core().portal_accent; + if let Some(a) = portal_accent { + let t_inner = theme.cosmic(); + if a.distance_squared(*t_inner.accent_color()) > 0.00001 { + theme = Theme::system(Arc::new(t_inner.with_accent(a))); + } + }; } - THEME.with(move |t| { - let mut cosmic_theme = t.borrow_mut(); - cosmic_theme.set_theme(theme.theme_type); - }); + THEME.lock().unwrap().set_theme(theme.theme_type); } - Message::SystemThemeChange(theme) => { + Action::SystemThemeChange(keys, theme) => { + let cur_is_dark = THEME.lock().unwrap().theme_type.is_dark(); + // Ignore updates if the current theme mode does not match. + if cur_is_dark != theme.cosmic().is_dark { + return iced::Task::none(); + } + let cmd = self.app.system_theme_update(&keys, theme.cosmic()); // Record the last-known system theme in event that the current theme is custom. self.app.core_mut().system_theme = theme.clone(); - THEME.with(move |t| { - let mut cosmic_theme = t.borrow_mut(); + let portal_accent = self.app.core().portal_accent; + { + let mut cosmic_theme = THEME.lock().unwrap(); // Only apply update if the theme is set to load a system theme - if let ThemeType::System(_) = cosmic_theme.theme_type { - cosmic_theme.set_theme(theme.theme_type); + if let ThemeType::System { + theme: _, + prefer_dark, + } = cosmic_theme.theme_type + { + let mut new_theme = if let Some(a) = portal_accent { + let t_inner = theme.cosmic(); + if a.distance_squared(*t_inner.accent_color()) > 0.00001 { + Theme::system(Arc::new(t_inner.with_accent(a))) + } else { + theme + } + } else { + theme + }; + new_theme.theme_type.prefer_dark(prefer_dark); + + cosmic_theme.set_theme(new_theme.theme_type); + #[cfg(all(feature = "wayland", target_os = "linux"))] + if self.app.core().sync_window_border_radii_to_theme() { + use iced_runtime::platform_specific::wayland::CornerRadius; + use iced_winit::platform_specific::commands::corner_radius::corner_radius; + + let t = cosmic_theme.cosmic(); + + let radii = t.radius_s().map(|x| if x < 4.0 { x } else { x + 4.0 }); + let cur_rad = CornerRadius { + top_left: radii[0].round() as u32, + top_right: radii[1].round() as u32, + bottom_right: radii[2].round() as u32, + bottom_left: radii[3].round() as u32, + }; + + let rounded = !self.app.core().window.sharp_corners; + // Update radius for the main window + let main_window_id = self + .app + .core() + .main_window_id() + .unwrap_or(window::Id::RESERVED); + let mut cmds = vec![ + corner_radius( + main_window_id, + if rounded { + Some(cur_rad) + } else { + let rad_0 = t.radius_0(); + Some(CornerRadius { + top_left: rad_0[0].round() as u32, + top_right: rad_0[1].round() as u32, + bottom_right: rad_0[2].round() as u32, + bottom_left: rad_0[3].round() as u32, + }) + }, + ) + .discard(), + ]; + // Update radius for each tracked view with the window surface type + for (id, (_, surface_type, _)) in self.surface_views.iter() { + if let SurfaceIdWrapper::Window(_) = surface_type { + cmds.push( + corner_radius( + *id, + if rounded { + Some(cur_rad) + } else { + let rad_0 = t.radius_0(); + Some(CornerRadius { + top_left: rad_0[0].round() as u32, + top_right: rad_0[1].round() as u32, + bottom_right: rad_0[2].round() as u32, + bottom_left: rad_0[3].round() as u32, + }) + }, + ) + .discard(), + ); + } + } + // Update radius for all tracked windows + for id in self.tracked_windows.iter() { + cmds.push( + corner_radius( + *id, + if rounded { + Some(cur_rad) + } else { + let rad_0 = t.radius_0(); + Some(CornerRadius { + top_left: rad_0[0].round() as u32, + top_right: rad_0[1].round() as u32, + bottom_right: rad_0[2].round() as u32, + bottom_left: rad_0[3].round() as u32, + }) + }, + ) + .discard(), + ); + } + + return Task::batch(cmds); + } } - }); + } + + return cmd; } - Message::ScaleFactor(factor) => { + Action::ScaleFactor(factor) => { self.app.core_mut().set_scale_factor(factor); } - Message::Close => { - self.app.on_app_exit(); - return self.close(); + Action::Close => { + return match self.app.on_app_exit() { + Some(message) => self.app.update(message), + None => self.close(), + }; } - Message::SystemThemeModeChange(mode) => { + Action::SystemThemeModeChange(keys, mode) => { + if match THEME.lock().unwrap().theme_type { + ThemeType::System { + theme: _, + prefer_dark, + } => prefer_dark.is_some(), + _ => false, + } { + return iced::Task::none(); + } + let mut cmds = vec![self.app.system_theme_mode_update(&keys, &mode)]; + let core = self.app.core_mut(); - let changed = core.system_theme_mode.is_dark != mode.is_dark; core.system_theme_mode = mode; - core.theme_sub_counter += 1; + let is_dark = core.system_is_dark(); + let changed = core.system_theme_mode.is_dark != is_dark + || core.portal_is_dark != Some(is_dark) + || core.system_theme.cosmic().is_dark != is_dark; if changed { - let new_theme = crate::theme::system_preference(); + core.theme_sub_counter += 1; + let mut new_theme = if is_dark { + crate::theme::system_dark() + } else { + crate::theme::system_light() + }; + cmds.push(self.app.system_theme_update(&[], new_theme.cosmic())); + + let core = self.app.core_mut(); + new_theme = if let Some(a) = core.portal_accent { + let t_inner = new_theme.cosmic(); + if a.distance_squared(*t_inner.accent_color()) > 0.00001 { + Theme::system(Arc::new(t_inner.with_accent(a))) + } else { + new_theme + } + } else { + new_theme + }; + core.system_theme = new_theme.clone(); - THEME.with(move |t| { - let mut cosmic_theme = t.borrow_mut(); + { + let mut cosmic_theme = THEME.lock().unwrap(); // Only apply update if the theme is set to load a system theme - if let ThemeType::System(_) = cosmic_theme.theme_type { + if let ThemeType::System { .. } = cosmic_theme.theme_type { cosmic_theme.set_theme(new_theme.theme_type); + #[cfg(all(feature = "wayland", target_os = "linux"))] + if self.app.core().sync_window_border_radii_to_theme() { + use iced_runtime::platform_specific::wayland::CornerRadius; + use iced_winit::platform_specific::commands::corner_radius::corner_radius; + + let t = cosmic_theme.cosmic(); + + let radii = t.radius_s().map(|x| if x < 4.0 { x } else { x + 4.0 }); + let cur_rad = CornerRadius { + top_left: radii[0].round() as u32, + top_right: radii[1].round() as u32, + bottom_right: radii[2].round() as u32, + bottom_left: radii[3].round() as u32, + }; + let rounded = !self.app.core().window.sharp_corners; + + // Update radius for the main window + let main_window_id = self + .app + .core() + .main_window_id() + .unwrap_or(window::Id::RESERVED); + let mut cmds = vec![ + corner_radius( + main_window_id, + if rounded { + Some(cur_rad) + } else { + let rad_0 = t.radius_0(); + Some(CornerRadius { + top_left: rad_0[0].round() as u32, + top_right: rad_0[1].round() as u32, + bottom_right: rad_0[2].round() as u32, + bottom_left: rad_0[3].round() as u32, + }) + }, + ) + .discard(), + ]; + // Update radius for each tracked view with the window surface type + for (id, (_, surface_type, _)) in self.surface_views.iter() { + if let SurfaceIdWrapper::Window(_) = surface_type { + cmds.push( + corner_radius( + *id, + if rounded { + Some(cur_rad) + } else { + let rad_0 = t.radius_0(); + Some(CornerRadius { + top_left: rad_0[0].round() as u32, + top_right: rad_0[1].round() as u32, + bottom_right: rad_0[2].round() as u32, + bottom_left: rad_0[3].round() as u32, + }) + }, + ) + .discard(), + ); + } + } + // Update radius for all tracked windows + for id in self.tracked_windows.iter() { + cmds.push( + corner_radius( + *id, + if rounded { + Some(cur_rad) + } else { + let rad_0 = t.radius_0(); + Some(CornerRadius { + top_left: rad_0[0].round() as u32, + top_right: rad_0[1].round() as u32, + bottom_right: rad_0[2].round() as u32, + bottom_left: rad_0[3].round() as u32, + }) + }, + ) + .discard(), + ); + } + + return Task::batch(cmds); + } } - }); + } + } + return Task::batch(cmds); + } + Action::Activate(_token) => { + if let Some(id) = self.app.core().main_window_id() { + // Unminimize window before requesting to activate it. + let mut task = iced_runtime::window::minimize(id, false); + + #[cfg(all(feature = "wayland", target_os = "linux"))] + { + task = task.chain( + iced_winit::platform_specific::commands::activation::activate( + id, + #[allow(clippy::used_underscore_binding)] + _token, + ), + ) + } + + #[cfg(not(all(feature = "wayland", target_os = "linux")))] + { + task = task.chain(iced_runtime::window::gain_focus(id)); + } + + return task; } } - Message::Activate(_token) => { - #[cfg(feature = "wayland")] - return iced_sctk::commands::activation::activate( - iced::window::Id::default(), - #[allow(clippy::used_underscore_binding)] - _token, - ); + + Action::Surface(action) => return self.surface_update(action), + + Action::SurfaceClosed(id) => { + if self.opened_surfaces.get_mut(&id).is_some_and(|v| { + *v = v.saturating_sub(1); + *v == 0 + }) { + self.opened_surfaces.remove(&id); + #[cfg(all(feature = "wayland", target_os = "linux"))] + self.surface_views.remove(&id); + self.tracked_windows.remove(&id); + } + + let mut ret = if let Some(msg) = self.app.on_close_requested(id) { + self.app.update(msg) + } else { + Task::none() + }; + let core = self.app.core(); + if core.exit_on_main_window_closed + && core.main_window_id().is_some_and(|m_id| id == m_id) + { + ret = Task::batch([iced::exit::>()]); + } + return ret; } + + Action::ShowWindowMenu => { + if let Some(id) = self.app.core().main_window_id() { + return iced::window::show_system_menu(id); + } + } + + #[cfg(feature = "single-instance")] + Action::DbusConnection(conn) => { + return self.app.dbus_connection(conn); + } + + #[cfg(feature = "xdg-portal")] + Action::DesktopSettings(crate::theme::portal::Desktop::ColorScheme(s)) => { + use ashpd::desktop::settings::ColorScheme; + if match THEME.lock().unwrap().theme_type { + ThemeType::System { + theme: _, + prefer_dark, + } => prefer_dark.is_some(), + _ => false, + } { + return iced::Task::none(); + } + let is_dark = match s { + ColorScheme::NoPreference => None, + ColorScheme::PreferDark => Some(true), + ColorScheme::PreferLight => Some(false), + }; + let core = self.app.core_mut(); + + core.portal_is_dark = is_dark; + let is_dark = core.system_is_dark(); + let changed = core.system_theme_mode.is_dark != is_dark + || core.portal_is_dark != Some(is_dark) + || core.system_theme.cosmic().is_dark != is_dark; + + if changed { + core.theme_sub_counter += 1; + let new_theme = if is_dark { + crate::theme::system_dark() + } else { + crate::theme::system_light() + }; + core.system_theme = new_theme.clone(); + { + let mut cosmic_theme = THEME.lock().unwrap(); + + // Only apply update if the theme is set to load a system theme + if let ThemeType::System { theme: _, .. } = cosmic_theme.theme_type { + cosmic_theme.set_theme(new_theme.theme_type); + } + } + } + } + #[cfg(feature = "xdg-portal")] + Action::DesktopSettings(crate::theme::portal::Desktop::Accent(c)) => { + use palette::Srgba; + let c = Srgba::new(c.red() as f32, c.green() as f32, c.blue() as f32, 1.0); + let core = self.app.core_mut(); + core.portal_accent = Some(c); + let cur_accent = core.system_theme.cosmic().accent_color(); + + if cur_accent.distance_squared(*c) < 0.00001 { + // skip calculations if we already have the same color + return iced::Task::none(); + } + + { + let mut cosmic_theme = THEME.lock().unwrap(); + + // Only apply update if the theme is set to load a system theme + if let ThemeType::System { + theme: t, + prefer_dark, + } = cosmic_theme.theme_type.clone() + { + cosmic_theme.set_theme(ThemeType::System { + theme: Arc::new(t.with_accent(c)), + prefer_dark, + }); + } + } + } + #[cfg(feature = "xdg-portal")] + Action::DesktopSettings(crate::theme::portal::Desktop::Contrast(_)) => { + // TODO when high contrast is integrated in settings and all custom themes + } + + Action::ToolkitConfig(config) => { + // Change the icon theme if not defined by the application. + if !self.app.core().icon_theme_override + && crate::icon_theme::default() != config.icon_theme + { + crate::icon_theme::set_default(config.icon_theme.clone()); + } + + *crate::config::COSMIC_TK.write().unwrap() = config; + } + + Action::Focus(f) => { + #[cfg(all( + feature = "wayland", + feature = "multi-window", + feature = "surface-message", + target_os = "linux" + ))] + if let Some(( + parent, + SurfaceIdWrapper::Subsurface(_) | SurfaceIdWrapper::Popup(_), + _, + )) = self.surface_views.get(&f) + { + // If the parent is already focused, push the new focus + // to the end of the focus chain. + if parent.is_some_and(|p| self.app.core().focused_window.last() == Some(&p)) { + self.app.core_mut().focused_window.push(f); + return iced::Task::none(); + } else { + // set the whole parent chain to the focus chain + let mut parent_chain = vec![f]; + let mut cur = *parent; + while let Some(p) = cur { + parent_chain.push(p); + cur = self + .surface_views + .get(&p) + .and_then(|(parent, _, _)| *parent); + } + parent_chain.reverse(); + self.app.core_mut().focused_window = parent_chain; + return iced::Task::none(); + } + } + self.app.core_mut().focused_window = vec![f]; + } + + Action::Unfocus(id) => { + let core = self.app.core_mut(); + if core.focused_window().as_ref().is_some_and(|cur| *cur == id) { + core.focused_window.pop(); + } + } + #[cfg(feature = "applet")] + Action::SuggestedBounds(b) => { + tracing::info!("Suggested bounds: {b:?}"); + let core = self.app.core_mut(); + core.applet.suggested_bounds = b; + } + Action::Opened(id) => { + #[cfg(all(feature = "wayland", target_os = "linux"))] + if self.app.core().sync_window_border_radii_to_theme() { + use iced_runtime::platform_specific::wayland::CornerRadius; + use iced_winit::platform_specific::commands::corner_radius::corner_radius; + + let theme = THEME.lock().unwrap(); + let t = theme.cosmic(); + let radii = t.radius_s().map(|x| if x < 4.0 { x } else { x + 4.0 }); + let cur_rad = CornerRadius { + top_left: radii[0].round() as u32, + top_right: radii[1].round() as u32, + bottom_right: radii[2].round() as u32, + bottom_left: radii[3].round() as u32, + }; + // TODO do we need per window sharp corners? + let rounded = !self.app.core().window.sharp_corners; + + return Task::batch([ + corner_radius( + id, + if rounded { + Some(cur_rad) + } else { + let rad_0 = t.radius_0(); + Some(CornerRadius { + top_left: rad_0[0].round() as u32, + top_right: rad_0[1].round() as u32, + bottom_right: rad_0[2].round() as u32, + bottom_left: rad_0[3].round() as u32, + }) + }, + ) + .discard(), + iced_runtime::window::run_with_handle(id, init_windowing_system), + ]); + } + return iced_runtime::window::run_with_handle(id, init_windowing_system); + } + _ => {} } - iced::Command::none() + iced::Task::none() } } @@ -384,8 +1296,81 @@ impl Cosmic { pub fn new(app: App) -> Self { Self { app, - #[cfg(feature = "wayland")] - should_exit: false, + #[cfg(all(feature = "wayland", target_os = "linux"))] + surface_views: HashMap::new(), + tracked_windows: HashSet::new(), + opened_surfaces: HashMap::new(), } } + + #[cfg(all(feature = "wayland", target_os = "linux"))] + /// Create a subsurface + pub fn get_subsurface( + &mut self, + settings: iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings, + view: Box< + dyn for<'a> Fn(&'a App) -> Element<'a, crate::Action> + Send + Sync, + >, + ) -> Task> { + use iced_winit::commands::subsurface::get_subsurface; + + *self.opened_surfaces.entry(settings.id).or_insert_with(|| 0) += 1; + self.surface_views.insert( + settings.id, + ( + Some(settings.parent), + SurfaceIdWrapper::Subsurface(settings.id), + view, + ), + ); + get_subsurface(settings) + } + + #[cfg(all(feature = "wayland", target_os = "linux"))] + /// Create a subsurface + pub fn get_popup( + &mut self, + settings: iced_runtime::platform_specific::wayland::popup::SctkPopupSettings, + view: Box< + dyn for<'a> Fn(&'a App) -> Element<'a, crate::Action> + Send + Sync, + >, + ) -> Task> { + use iced_winit::commands::popup::get_popup; + *self.opened_surfaces.entry(settings.id).or_insert_with(|| 0) += 1; + self.surface_views.insert( + settings.id, + ( + Some(settings.parent), + SurfaceIdWrapper::Popup(settings.id), + view, + ), + ); + get_popup(settings) + } + + #[cfg(all(feature = "wayland", target_os = "linux"))] + /// Create a window surface + pub fn get_window( + &mut self, + id: iced::window::Id, + settings: iced::window::Settings, + view: Box< + dyn for<'a> Fn(&'a App) -> Element<'a, crate::Action> + Send + Sync, + >, + ) -> Task> { + use iced_winit::SurfaceIdWrapper; + *self.opened_surfaces.entry(id).or_insert(0) += 1; + self.surface_views.insert( + id, + ( + None, // TODO parent for window, platform specific option maybe? + SurfaceIdWrapper::Window(id), + view, + ), + ); + iced_runtime::task::oneshot(|channel| { + iced_runtime::Action::Window(iced_runtime::window::Action::Open(id, settings, channel)) + }) + .discard() + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 328fa9c6..f78beac7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,266 +6,183 @@ //! Check out our [application](https://github.com/pop-os/libcosmic/tree/master/examples/application) //! example in our repository. -pub mod command; -mod core; +mod action; +pub use action::Action; +use cosmic_config::CosmicConfigEntry; +pub mod context_drawer; +pub use context_drawer::{ContextDrawer, context_drawer}; +use iced::application::BootFn; pub mod cosmic; pub mod settings; -pub mod message { - #[derive(Clone, Debug)] - #[must_use] - pub enum Message { - /// Messages from the application, for the application. - App(M), - /// Internal messages to be handled by libcosmic. - Cosmic(super::cosmic::Message), - #[cfg(feature = "single-instance")] - /// Dbus activation messages - DbusActivation(super::DbusActivationMessage), - /// Do nothing - None, - } +pub type Task = iced::Task>; - pub const fn app(message: M) -> Message { - Message::App(message) - } - - pub const fn cosmic(message: super::cosmic::Message) -> Message { - Message::Cosmic(message) - } - - pub const fn none() -> Message { - Message::None - } -} - -pub use self::command::Command; -pub use self::core::Core; -pub use self::settings::Settings; +pub use crate::Core; use crate::prelude::*; use crate::theme::THEME; -use crate::widget::{context_drawer, nav_bar}; +use crate::widget::{container, id_container, menu, nav_bar, popover, space}; use apply::Apply; -use iced::Subscription; -use iced::{window, Application as IcedApplication}; -pub use message::Message; -use url::Url; -#[cfg(feature = "single-instance")] -use { - iced_futures::futures::channel::mpsc::{Receiver, Sender}, - iced_futures::futures::SinkExt, - std::any::TypeId, - std::collections::HashMap, - zbus::{dbus_interface, dbus_proxy, zvariant::Value}, -}; +use iced::{Length, Subscription}; +use iced::{theme, window}; +pub use settings::Settings; +use std::borrow::Cow; +use std::{cell::RefCell, rc::Rc}; +#[cold] pub(crate) fn iced_settings( settings: Settings, flags: App::Flags, -) -> iced::Settings<(Core, App::Flags)> { - if let Some(icon_theme) = settings.default_icon_theme { - crate::icon_theme::set_default(icon_theme); - } +) -> (iced::Settings, (Core, App::Flags), iced::window::Settings) { + preload_fonts(); let mut core = Core::default(); core.debug = settings.debug; + core.icon_theme_override = settings.default_icon_theme.is_some(); core.set_scale_factor(settings.scale_factor); - core.set_window_width(settings.size.0); - core.set_window_height(settings.size.1); + core.set_window_width(settings.size.width); + core.set_window_height(settings.size.height); - THEME.with(move |t| { - let mut cosmic_theme = t.borrow_mut(); - cosmic_theme.set_theme(settings.theme.theme_type); - }); + if let Some(icon_theme) = settings.default_icon_theme { + crate::icon_theme::set_default(icon_theme); + } else { + crate::icon_theme::set_default(crate::config::icon_theme()); + } - let mut iced = iced::Settings::with_flags((core, flags)); + THEME.lock().unwrap().set_theme(settings.theme.theme_type); + + if settings.no_main_window { + core.main_window = Some(iced::window::Id::NONE); + } + + let mut iced = iced::Settings::default(); iced.antialiasing = settings.antialiasing; iced.default_font = settings.default_font; - iced.default_text_size = settings.default_text_size; - iced.exit_on_close_request = settings.exit_on_close; + iced.default_text_size = iced::Pixels(settings.default_text_size); + let exit_on_close = settings.exit_on_close; + iced.is_daemon = false; + iced.exit_on_close_request = settings.is_daemon; + let mut window_settings = iced::window::Settings::default(); + window_settings.exit_on_close_request = exit_on_close; iced.id = Some(App::APP_ID.to_owned()); - - #[cfg(feature = "wayland")] + #[cfg(target_os = "linux")] { - use iced::wayland::actions::window::SctkWindowSettings; - use iced_sctk::settings::InitialSurface; - iced.initial_surface = if settings.no_main_window { - InitialSurface::None - } else { - InitialSurface::XdgWindow(SctkWindowSettings { - app_id: Some(App::APP_ID.to_owned()), - autosize: settings.autosize, - client_decorations: settings.client_decorations, - resizable: settings.resizable, - size: settings.size, - size_limits: settings.size_limits, - title: None, - transparent: settings.transparent, - xdg_activation_token: std::env::var("XDG_ACTIVATION_TOKEN").ok(), - ..SctkWindowSettings::default() - }) - }; + window_settings.platform_specific.application_id = App::APP_ID.to_string(); + } + core.exit_on_main_window_closed = exit_on_close; + + if let Some(border_size) = settings.resizable { + window_settings.resize_border = border_size as u32; + window_settings.resizable = true; + } + window_settings.decorations = !settings.client_decorations; + window_settings.size = settings.size; + let min_size = settings.size_limits.min(); + if min_size != iced::Size::ZERO { + window_settings.min_size = Some(min_size); + } + let max_size = settings.size_limits.max(); + if max_size != iced::Size::INFINITE { + window_settings.max_size = Some(max_size); } - #[cfg(not(feature = "wayland"))] - { - if let Some(_border_size) = settings.resizable { - // iced.window.border_size = border_size as u32; - iced.window.resizable = true; - } - iced.window.decorations = !settings.client_decorations; - iced.window.size = settings.size; - iced.window.transparent = settings.transparent; - } - - iced + window_settings.transparent = settings.transparent; + (iced, (core, flags), window_settings) } +pub(crate) struct BootDataInner { + pub flags: A::Flags, + pub core: Core, + pub settings: window::Settings, +} + +pub(crate) struct BootData(pub Rc>>>); + +impl BootFn, crate::Action> + for BootData +{ + fn boot(&self) -> (cosmic::Cosmic, iced::Task>) { + let mut data = self.0.borrow_mut(); + let mut data = data.take().unwrap(); + let mut tasks = Vec::new(); + #[cfg(feature = "multi-window")] + if data.core.main_window_id().is_some() { + let window_task = iced_runtime::task::oneshot(|channel| { + iced_runtime::Action::Window(iced_runtime::window::Action::Open( + window::Id::RESERVED, + data.settings, + channel, + )) + }); + data.core.set_main_window_id(Some(window::Id::RESERVED)); + tasks.push(window_task.discard()); + } + let (a, t) = cosmic::Cosmic::::init((data.core, data.flags)); + tasks.push(t); + (a, Task::batch(tasks)) + } +} /// Launch a COSMIC application with the given [`Settings`]. /// /// # Errors /// /// Returns error on application failure. pub fn run(settings: Settings, flags: App::Flags) -> iced::Result { - let settings = iced_settings::(settings, flags); + #[cfg(feature = "desktop")] + image_extras::register(); - cosmic::Cosmic::::run(settings) -} - -#[cfg(feature = "single-instance")] -#[derive(Debug, Clone)] -pub struct DbusActivationMessage> { - pub activation_token: Option, - pub desktop_startup_id: Option, - pub msg: DbusActivationDetails, -} - -#[derive(Debug, Clone)] -pub enum DbusActivationDetails> { - Activate, - Open { - url: Vec, - }, - /// action can be deserialized as Flags - ActivateAction { - action: Action, - args: Args, - }, -} -#[cfg(feature = "single-instance")] -#[derive(Debug, Default)] -pub struct DbusActivation(Option>); -#[cfg(feature = "single-instance")] -impl DbusActivation { - #[must_use] - pub fn new() -> Self { - Self(None) + #[cfg(all(target_env = "gnu", not(target_os = "windows")))] + if let Some(threshold) = settings.default_mmap_threshold { + crate::malloc::limit_mmap_threshold(threshold); } - pub fn rx(&mut self) -> Receiver { - let (tx, rx) = iced_futures::futures::channel::mpsc::channel(10); - self.0 = Some(tx); - rx + let default_font = settings.default_font; + let (settings, (mut core, flags), window_settings) = iced_settings::(settings, flags); + #[cfg(not(feature = "multi-window"))] + { + core.main_window = Some(iced::window::Id::RESERVED); + + iced::application( + BootData(Rc::new(RefCell::new(Some(BootDataInner:: { + flags, + core, + settings: window_settings.clone(), + })))), + cosmic::Cosmic::update, + cosmic::Cosmic::view, + ) + .subscription(cosmic::Cosmic::subscription) + .title(cosmic::Cosmic::title) + .style(cosmic::Cosmic::style) + .theme(cosmic::Cosmic::theme) + .window_size((500.0, 800.0)) + .settings(settings) + .window(window_settings) + .run() } -} - -#[cfg(feature = "single-instance")] -#[dbus_proxy(interface = "org.freedesktop.DbusActivation", assume_defaults = true)] -pub trait DbusActivationInterface { - /// Activate the application. - fn activate(&mut self, platform_data: HashMap<&str, Value<'_>>) -> zbus::Result<()>; - - /// Open the given URIs. - fn open( - &mut self, - uris: Vec<&str>, - platform_data: HashMap<&str, Value<'_>>, - ) -> zbus::Result<()>; - - /// Activate the given action. - fn activate_action( - &mut self, - action_name: &str, - parameter: Vec<&str>, - platform_data: HashMap<&str, Value<'_>>, - ) -> zbus::Result<()>; -} - -#[cfg(feature = "single-instance")] -#[dbus_interface(name = "org.freedesktop.DbusActivation")] -impl DbusActivation { - async fn activate(&mut self, platform_data: HashMap<&str, Value<'_>>) { - if let Some(tx) = &mut self.0 { - let _ = tx - .send(DbusActivationMessage { - activation_token: platform_data.get("activation-token").and_then(|t| match t { - Value::Str(t) => Some(t.to_string()), - _ => None, - }), - desktop_startup_id: platform_data.get("desktop-startup-id").and_then( - |t| match t { - Value::Str(t) => Some(t.to_string()), - _ => None, - }, - ), - msg: DbusActivationDetails::Activate, - }) - .await; + #[cfg(feature = "multi-window")] + { + let no_main_window = core.main_window.is_none(); + if no_main_window { + // app = app.window(window_settings); + core.main_window = Some(iced_core::window::Id::RESERVED); } - } + let app = iced::daemon( + BootData(Rc::new(RefCell::new(Some(BootDataInner:: { + flags, + core, + settings: window_settings, + })))), + cosmic::Cosmic::update, + cosmic::Cosmic::view, + ); - async fn open(&mut self, uris: Vec<&str>, platform_data: HashMap<&str, Value<'_>>) { - if let Some(tx) = &mut self.0 { - let _ = tx - .send(DbusActivationMessage { - activation_token: platform_data.get("activation-token").and_then(|t| match t { - Value::Str(t) => Some(t.to_string()), - _ => None, - }), - desktop_startup_id: platform_data.get("desktop-startup-id").and_then( - |t| match t { - Value::Str(t) => Some(t.to_string()), - _ => None, - }, - ), - msg: DbusActivationDetails::Open { - url: uris.iter().filter_map(|u| Url::parse(u).ok()).collect(), - }, - }) - .await; - } - } - - async fn activate_action( - &mut self, - action_name: &str, - parameter: Vec<&str>, - platform_data: HashMap<&str, Value<'_>>, - ) { - if let Some(tx) = &mut self.0 { - let _ = tx - .send(DbusActivationMessage { - activation_token: platform_data.get("activation-token").and_then(|t| match t { - Value::Str(t) => Some(t.to_string()), - _ => None, - }), - desktop_startup_id: platform_data.get("desktop-startup-id").and_then( - |t| match t { - Value::Str(t) => Some(t.to_string()), - _ => None, - }, - ), - msg: DbusActivationDetails::ActivateAction { - action: action_name.to_string(), - args: parameter - .iter() - .map(std::string::ToString::to_string) - .collect(), - }, - }) - .await; - } + app.subscription(cosmic::Cosmic::subscription) + .title(cosmic::Cosmic::title) + .style(cosmic::Cosmic::style) + .theme(cosmic::Cosmic::theme) + .settings(settings) + .run() } } @@ -277,9 +194,14 @@ impl DbusActivation { /// Returns error on application failure. pub fn run_single_instance(settings: Settings, flags: App::Flags) -> iced::Result where - App::Flags: CosmicFlags + Clone, + App::Flags: CosmicFlags, App::Message: Clone + std::fmt::Debug + Send + 'static, { + #[cfg(feature = "desktop")] + image_extras::register(); + + use std::collections::HashMap; + let activation_token = std::env::var("XDG_ACTIVATION_TOKEN").ok(); let override_single = std::env::var("COSMIC_SINGLE_INSTANCE") @@ -296,14 +218,14 @@ where return run::(settings, flags); }; - if DbusActivationInterfaceProxyBlocking::builder(&conn) + if crate::dbus_activation::DbusActivationInterfaceProxyBlocking::builder(&conn) .destination(App::APP_ID) .ok() .and_then(|b| b.path(path).ok()) .and_then(|b| b.destination(App::APP_ID).ok()) .and_then(|b| b.build().ok()) .is_some_and(|mut p| { - match { + let res = { let mut platform_data = HashMap::new(); if let Some(activation_token) = activation_token { platform_data.insert("activation-token", activation_token.into()); @@ -317,7 +239,8 @@ where } else { p.activate(platform_data) } - } { + }; + match res { Ok(()) => { tracing::info!("Successfully activated another instance"); true @@ -332,9 +255,52 @@ where tracing::info!("Another instance is running"); Ok(()) } else { - let mut settings = iced_settings::(settings, flags); - settings.flags.0.single_instance = true; - cosmic::Cosmic::::run(settings) + let (settings, (mut core, flags), window_settings) = iced_settings::(settings, flags); + core.single_instance = true; + + #[cfg(not(feature = "multi-window"))] + { + iced::application( + BootData(Rc::new(RefCell::new(Some(BootDataInner:: { + flags, + core, + settings: window_settings.clone(), + })))), + cosmic::Cosmic::update, + cosmic::Cosmic::view, + ) + .subscription(cosmic::Cosmic::subscription) + .style(cosmic::Cosmic::style) + .theme(cosmic::Cosmic::theme) + .window_size((500.0, 800.0)) + .settings(settings) + .window(window_settings) + .run() + } + #[cfg(feature = "multi-window")] + { + let no_main_window = core.main_window.is_none(); + if no_main_window { + // app = app.window(window_settings); + core.main_window = Some(iced_core::window::Id::RESERVED); + } + let mut app = iced::daemon( + BootData(Rc::new(RefCell::new(Some(BootDataInner:: { + flags, + core, + settings: window_settings, + })))), + cosmic::Cosmic::update, + cosmic::Cosmic::view, + ); + + app.subscription(cosmic::Cosmic::subscription) + .style(cosmic::Cosmic::style) + .title(cosmic::Cosmic::title) + .theme(cosmic::Cosmic::theme) + .settings(settings) + .run() + } } } @@ -362,7 +328,7 @@ where type Executor: iced_futures::Executor; /// Argument received [`Application::new`]. - type Flags: Clone; + type Flags; /// Message type specific to our app. type Message: Clone + std::fmt::Debug + Send + 'static; @@ -378,59 +344,79 @@ where /// Grants access to the COSMIC Core. fn core_mut(&mut self) -> &mut Core; - /// Creates the application, and optionally emits command on initialize. - fn init(core: Core, flags: Self::Flags) -> (Self, iced::Command>); + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, flags: Self::Flags) -> (Self, Task); /// Displays a context drawer on the side of the application window when `Some`. - fn context_drawer(&self) -> Option> { + /// Use the [`ApplicationExt::set_show_context`] function for this to take effect. + fn context_drawer(&self) -> Option> { + None + } + + /// Displays a dialog in the center of the application window when `Some`. + fn dialog(&self) -> Option> { + None + } + + /// Displays a footer at the bottom of the application window when `Some`. + fn footer(&self) -> Option> { None } /// Attaches elements to the start section of the header. - fn header_start(&self) -> Vec> { + fn header_start(&self) -> Vec> { Vec::new() } /// Attaches elements to the center of the header. - fn header_center(&self) -> Vec> { + fn header_center(&self) -> Vec> { Vec::new() } /// Attaches elements to the end section of the header. - fn header_end(&self) -> Vec> { + fn header_end(&self) -> Vec> { Vec::new() } - /// Allows overriding the default nav bar widget - fn nav_bar(&self) -> Option>> { + /// Allows overriding the default nav bar widget. + fn nav_bar(&self) -> Option>> { if !self.core().nav_bar_active() { return None; } let nav_model = self.nav_model()?; - let mut nav = crate::widget::nav_bar(nav_model, |entity| { - Message::Cosmic(cosmic::Message::NavBar(entity)) - }); + let mut nav = + crate::widget::nav_bar(nav_model, |id| crate::Action::Cosmic(Action::NavBar(id))) + .on_context(|id| crate::Action::Cosmic(Action::NavBarContext(id))) + .context_menu(self.nav_context_menu(self.core().nav_bar_context())) + .into_container() + .width(iced::Length::Shrink) + .height(iced::Length::Fill); if !self.core().is_condensed() { - nav = nav.max_width(300); + nav = nav.max_width(280); } Some(Element::from(nav)) } + /// Shows a context menu for the active nav bar item. + fn nav_context_menu( + &self, + id: nav_bar::Id, + ) -> Option>>> { + None + } + /// Allows COSMIC to integrate with your application's [`nav_bar::Model`]. fn nav_model(&self) -> Option<&nav_bar::Model> { None } - /// Called before closing the application. - fn on_app_exit(&mut self) {} - - #[cfg(feature = "wayland")] - fn should_exit(&self) -> bool { - false + /// Called before closing the application. Returning a message will override closing windows. + fn on_app_exit(&mut self) -> Option { + None } /// Called when a window requests to be closed. @@ -438,23 +424,33 @@ where None } + // Called when context drawer is toggled + fn on_context_drawer(&mut self) -> Task { + Task::none() + } + /// Called when the escape key is pressed. - fn on_escape(&mut self) -> iced::Command> { - iced::Command::none() + fn on_escape(&mut self) -> Task { + Task::none() } /// Called when a navigation item is selected. - fn on_nav_select(&mut self, id: nav_bar::Id) -> iced::Command> { - iced::Command::none() + fn on_nav_select(&mut self, id: nav_bar::Id) -> Task { + Task::none() + } + + /// Called when a context menu is requested for a navigation item. + fn on_nav_context(&mut self, id: nav_bar::Id) -> Task { + Task::none() } /// Called when the search function is requested. - fn on_search(&mut self) -> iced::Command> { - iced::Command::none() + fn on_search(&mut self) -> Task { + Task::none() } /// Called when a window is resized. - fn on_window_resize(&mut self, id: window::Id, width: u32, height: u32) {} + fn on_window_resize(&mut self, id: window::Id, width: f32, height: f32) {} /// Event sources that are to be listened to. fn subscription(&self) -> Subscription { @@ -462,50 +458,78 @@ where } /// Respond to an application-specific message. - fn update(&mut self, message: Self::Message) -> iced::Command> { - iced::Command::none() + fn update(&mut self, message: Self::Message) -> Task { + Task::none() + } + + /// Respond to a system theme change + fn system_theme_update( + &mut self, + keys: &[&'static str], + new_theme: &cosmic_theme::Theme, + ) -> Task { + Task::none() + } + + /// Respond to a system theme mode change + fn system_theme_mode_update( + &mut self, + keys: &[&'static str], + new_theme: &cosmic_theme::ThemeMode, + ) -> Task { + Task::none() } /// Constructs the view for the main window. - fn view(&self) -> Element; + fn view(&self) -> Element<'_, Self::Message>; /// Constructs views for other windows. - fn view_window(&self, id: window::Id) -> Element { - panic!("no view for window {}", id.0); + fn view_window(&self, id: window::Id) -> Element<'_, Self::Message> { + panic!("no view for window {id:?}"); } /// Overrides the default style for applications - fn style(&self) -> Option<::Style> { + fn style(&self) -> Option { None } /// Handles dbus activation messages #[cfg(feature = "single-instance")] - fn dbus_activation( - &mut self, - msg: DbusActivationMessage, - ) -> iced::Command> { - iced::Command::none() + fn dbus_activation(&mut self, msg: crate::dbus_activation::Message) -> Task { + Task::none() + } + + /// Invoked on connect to dbus session socket used for dbus activation + /// + /// Can be used to expose custom interfaces on the same owned name. + #[cfg(feature = "single-instance")] + fn dbus_connection(&mut self, conn: zbus::Connection) -> Task { + Task::none() } } /// Methods automatically derived for all types implementing [`Application`]. pub trait ApplicationExt: Application { /// Initiates a window drag. - fn drag(&mut self) -> iced::Command>; + fn drag(&mut self) -> Task; - /// Fullscreens the window. - fn fullscreen(&mut self) -> iced::Command>; + /// Maximizes the window. + fn maximize(&mut self) -> Task; /// Minimizes the window. - fn minimize(&mut self) -> iced::Command>; - + fn minimize(&mut self) -> Task; /// Get the title of the main window. + + #[cfg(not(feature = "multi-window"))] fn title(&self) -> &str; - /// Set the context drawer title. - fn set_context_title(&mut self, title: String) { - self.core_mut().set_context_title(title); + #[cfg(feature = "multi-window")] + /// Get the title of a window. + fn title(&self, id: window::Id) -> &str; + + /// Set the context drawer visibility. + fn set_show_context(&mut self, show: bool) { + self.core_mut().set_show_context(show); } /// Set the header bar title. @@ -513,197 +537,370 @@ pub trait ApplicationExt: Application { self.core_mut().set_header_title(title); } + #[cfg(not(feature = "multi-window"))] /// Set the title of the main window. - fn set_window_title(&mut self, title: String) -> iced::Command>; + fn set_window_title(&mut self, title: String) -> Task; + + #[cfg(feature = "multi-window")] + /// Set the title of a window. + fn set_window_title(&mut self, title: String, id: window::Id) -> Task; /// View template for the main window. - fn view_main(&self) -> Element>; + fn view_main(&self) -> Element<'_, crate::Action>; + + fn watch_config( + &self, + id: &'static str, + ) -> iced::Subscription> { + self.core().watch_config(id) + } + + fn watch_state( + &self, + id: &'static str, + ) -> iced::Subscription> { + self.core().watch_state(id) + } } impl ApplicationExt for App { - fn drag(&mut self) -> iced::Command> { - command::drag() + fn drag(&mut self) -> Task { + self.core().drag(None) } - fn fullscreen(&mut self) -> iced::Command> { - command::fullscreen() + fn maximize(&mut self) -> Task { + self.core().maximize(None, true) } - fn minimize(&mut self) -> iced::Command> { - command::minimize() + fn minimize(&mut self) -> Task { + self.core().minimize(None) } + #[cfg(feature = "multi-window")] + fn title(&self, id: window::Id) -> &str { + self.core().title.get(&id).map_or("", |s| s.as_str()) + } + + #[cfg(not(feature = "multi-window"))] fn title(&self) -> &str { - &self.core().title + self.core() + .main_window_id() + .and_then(|id| self.core().title.get(&id).map(std::string::String::as_str)) + .unwrap_or("") } - #[cfg(feature = "wayland")] - fn set_window_title(&mut self, title: String) -> iced::Command> { - self.core_mut().title = title.clone(); - command::set_title(title) + #[cfg(feature = "multi-window")] + fn set_window_title(&mut self, title: String, id: window::Id) -> Task { + self.core_mut().title.insert(id, title.clone()); + self.core().set_title(Some(id), title) } - #[cfg(not(feature = "wayland"))] - fn set_window_title(&mut self, title: String) -> iced::Command> { - self.core_mut().title = title.clone(); - iced::Command::none() + #[cfg(not(feature = "multi-window"))] + fn set_window_title(&mut self, title: String) -> Task { + let Some(id) = self.core().main_window_id() else { + return Task::none(); + }; + + self.core_mut().title.insert(id, title.clone()); + Task::none() } + #[allow(clippy::too_many_lines)] /// Creates the view for the main window. - fn view_main(&self) -> Element> { + fn view_main(&self) -> Element<'_, crate::Action> { let core = self.core(); let is_condensed = core.is_condensed(); + let sharp_corners = core.window.sharp_corners; + let maximized = core.window.is_maximized; + let content_container = core.window.content_container; + let show_context = core.window.show_context; + let nav_bar_active = core.nav_bar_active(); + let focused = core + .focus_chain() + .iter() + .any(|i| Some(*i) == self.core().main_window_id()); - crate::widget::column::with_capacity(2) + let border_padding = if maximized { 8 } else { 7 }; + + let main_content_padding = if !content_container { + [0, 0, 0, 0] + } else { + let right_padding = if show_context { 0 } else { border_padding }; + let left_padding = if nav_bar_active { 0 } else { border_padding }; + + [0, right_padding, 0, left_padding] + }; + + let content_row = crate::widget::row::with_children({ + let mut widgets = Vec::with_capacity(3); + + // Insert nav bar onto the left side of the window. + let has_nav = if let Some(nav) = self + .nav_bar() + .map(|nav| id_container(nav, iced_core::id::Id::new("COSMIC_nav_bar"))) + { + widgets.push( + container(nav) + .padding([ + 0, + if is_condensed { border_padding } else { 8 }, + border_padding, + border_padding, + ]) + .into(), + ); + true + } else { + false + }; + + if self.nav_model().is_none() || core.show_content() { + let main_content = self.view(); + + //TODO: reduce duplication + let context_width = core.context_width(has_nav); + if core.window.context_is_overlay && show_context { + if let Some(context) = self.context_drawer() { + widgets.push( + crate::widget::context_drawer( + context.title, + context.actions, + context.header, + context.footer, + context.on_close, + main_content, + context.content, + context_width, + ) + .apply(|drawer| { + Element::from(id_container( + drawer, + iced_core::id::Id::new("COSMIC_context_drawer"), + )) + }) + .apply(container) + .padding([0, if content_container { border_padding } else { 0 }, 0, 0]) + .apply(Element::from) + .map(crate::Action::App), + ); + } else { + widgets.push( + container(main_content.map(crate::Action::App)) + .padding(main_content_padding) + .into(), + ); + } + } else { + //TODO: hide content when out of space + widgets.push( + container(main_content.map(crate::Action::App)) + .padding(main_content_padding) + .into(), + ); + if let Some(context) = self.context_drawer() { + widgets.push( + crate::widget::ContextDrawer::new_inner( + context.title, + context.actions, + context.header, + context.footer, + context.content, + context.on_close, + context_width, + ) + .apply(Element::from) + .map(crate::Action::App) + .apply(container) + .width(context_width) + .apply(|drawer| { + Element::from(id_container( + drawer, + iced_core::id::Id::new("COSMIC_context_drawer"), + )) + }) + .apply(container) + .padding(if content_container { + [0, border_padding, border_padding, border_padding] + } else { + [0, 0, 0, 0] + }) + .into(), + ); + } else { + //TODO: this element is added to workaround state issues + widgets.push(space::horizontal().width(Length::Shrink).into()); + } + } + } + + widgets + }); + + let content_col = crate::widget::column::with_capacity(2) + .push(content_row) + .push_maybe(self.footer().map(|footer| { + container(footer.map(crate::Action::App)).padding([ + 0, + border_padding, + border_padding, + border_padding, + ]) + })); + let content: Element<_> = if content_container { + content_col + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .apply(|w| id_container(w, iced_core::id::Id::new("COSMIC_content_container"))) + .into() + } else { + content_col.into() + }; + + // Ensures visually aligned radii for content and window corners + let window_corner_radius = if sharp_corners { + crate::theme::active().cosmic().radius_0() + } else { + crate::theme::active() + .cosmic() + .radius_s() + .map(|x| if x < 4.0 { x } else { x + 4.0 }) + }; + + let view_column = crate::widget::column::with_capacity(2) .push_maybe(if core.window.show_headerbar { Some({ let mut header = crate::widget::header_bar() + .focused(focused) + .maximized(maximized) + .sharp_corners(sharp_corners) + .transparent(content_container) .title(&core.window.header_title) - .on_drag(Message::Cosmic(cosmic::Message::Drag)) - .on_close(Message::Cosmic(cosmic::Message::Close)); + .on_drag(crate::Action::Cosmic(Action::Drag)) + .on_right_click(crate::Action::Cosmic(Action::ShowWindowMenu)) + .on_double_click(crate::Action::Cosmic(Action::Maximize)); if self.nav_model().is_some() { let toggle = crate::widget::nav_bar_toggle() .active(core.nav_bar_active()) + .selected(focused) .on_toggle(if is_condensed { - Message::Cosmic(cosmic::Message::ToggleNavBarCondensed) + crate::Action::Cosmic(Action::ToggleNavBarCondensed) } else { - Message::Cosmic(cosmic::Message::ToggleNavBar) + crate::Action::Cosmic(Action::ToggleNavBar) }); header = header.start(toggle); } - if core.window.show_maximize { - header = header.on_maximize(Message::Cosmic(cosmic::Message::Maximize)); + if core.window.show_close { + header = header.on_close(crate::Action::Cosmic(Action::Close)); } - if core.window.show_minimize { - header = header.on_minimize(Message::Cosmic(cosmic::Message::Minimize)); + if core.window.show_maximize && crate::config::show_maximize() { + header = header.on_maximize(crate::Action::Cosmic(Action::Maximize)); + } + + if core.window.show_minimize && crate::config::show_minimize() { + header = header.on_minimize(crate::Action::Cosmic(Action::Minimize)); } for element in self.header_start() { - header = header.start(element.map(Message::App)); + header = header.start(element.map(crate::Action::App)); } for element in self.header_center() { - header = header.center(element.map(Message::App)); + header = header.center(element.map(crate::Action::App)); } for element in self.header_end() { - header = header.end(element.map(Message::App)); + header = header.end(element.map(crate::Action::App)); } - header + if content_container { + header.apply(|w| id_container(w, iced_core::id::Id::new("COSMIC_header"))) + } else { + // Needed to avoid header bar corner gaps for apps without a content container + header + .apply(container) + .class(crate::theme::Container::custom(move |theme| { + let cosmic = theme.cosmic(); + container::Style { + background: Some(iced::Background::Color( + cosmic.background.base.into(), + )), + border: iced::Border { + radius: [ + (window_corner_radius[0] - 1.0).max(0.0), + (window_corner_radius[1] - 1.0).max(0.0), + cosmic.radius_0()[2], + cosmic.radius_0()[3], + ] + .into(), + ..Default::default() + }, + ..Default::default() + } + })) + .apply(|w| id_container(w, iced_core::id::Id::new("COSMIC_header"))) + } }) } else { None }) // The content element contains every element beneath the header. - .push( - crate::widget::row::with_children({ - let mut widgets = Vec::with_capacity(2); + .push(content) + .apply(container) + .padding(if maximized { 0 } else { 1 }) + .class(crate::theme::Container::custom(move |theme| { + container::Style { + background: if content_container { + Some(iced::Background::Color( + theme.cosmic().background.base.into(), + )) + } else { + None + }, + border: iced::Border { + color: theme.cosmic().bg_divider().into(), + width: if maximized { 0.0 } else { 1.0 }, + radius: window_corner_radius.into(), + }, + ..Default::default() + } + })); - // Insert nav bar onto the left side of the window. - if let Some(nav) = self.nav_bar() { - widgets.push(nav.debug(core.debug)); - } + // Show any current dialog on top and centered over the view content + // We have to use a popover even without a dialog to keep the tree from changing + let mut popover = popover(view_column).modal(true); + if let Some(dialog) = self + .dialog() + .map(|w| Element::from(id_container(w, iced_core::id::Id::new("COSMIC_dialog")))) + { + popover = popover.popup(dialog.map(crate::Action::App)); + } - if self.nav_model().is_none() || core.show_content() { - let main_content = self.view().debug(core.debug).map(Message::App); - - widgets.push(if let Some(context) = self.context_drawer() { - context_drawer( - &core.window.context_title, - Message::Cosmic(cosmic::Message::ContextDrawer(false)), - main_content, - context.map(Message::App), - ) - .into() - } else { - main_content - }); - } - - widgets - }) - .spacing(8) - .apply(crate::widget::container) - .padding([0, 8, 8, 8]) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .style(crate::theme::Container::Background), - ) - .into() + let view_element: Element<_> = popover.into(); + view_element.debug(core.debug) } } -#[cfg(feature = "single-instance")] -fn single_instance_subscription() -> Subscription> { - use iced_futures::futures::StreamExt; +const EMBEDDED_FONTS: &[&[u8]] = &[ + include_bytes!("../../res/open-sans/OpenSans-Light.ttf"), + include_bytes!("../../res/open-sans/OpenSans-Regular.ttf"), + include_bytes!("../../res/open-sans/OpenSans-Semibold.ttf"), + include_bytes!("../../res/open-sans/OpenSans-Bold.ttf"), + include_bytes!("../../res/open-sans/OpenSans-ExtraBold.ttf"), + include_bytes!("../../res/noto/NotoSansMono-Regular.ttf"), + include_bytes!("../../res/noto/NotoSansMono-Bold.ttf"), +]; - iced::subscription::channel( - TypeId::of::(), - 10, - move |mut output| async move { - let mut single_instance: DbusActivation = DbusActivation::new(); - let mut rx = single_instance.rx(); - if let Ok(builder) = zbus::ConnectionBuilder::session() { - let path: String = format!("/{}", App::APP_ID.replace('.', "/")); - if let Ok(conn) = builder.build().await { - // XXX Setup done this way seems to be more reliable. - // - // the docs for serve_at seem to imply it will replace the - // existing interface at the requested path, but it doesn't - // seem to work that way all the time. The docs for - // object_server().at() imply it won't replace the existing - // interface. - // - // request_name is used either way, with the builder or - // with the connection, but it must be done after the - // object server is setup. - if conn.object_server().at(path, single_instance).await != Ok(true) { - tracing::error!("Failed to serve dbus"); - std::process::exit(1); - } - if conn.request_name(App::APP_ID).await.is_err() { - tracing::error!("Failed to serve dbus"); - std::process::exit(1); - } +#[cold] +fn preload_fonts() { + let mut font_system = iced::advanced::graphics::text::font_system() + .write() + .unwrap(); - #[cfg(feature = "smol")] - let handle = { - std::thread::spawn(move || { - let conn_clone = _conn.clone(); - - zbus::block_on(async move { - loop { - conn_clone.executor().tick().await; - } - }) - }) - }; - while let Some(mut msg) = rx.next().await { - if let Some(token) = msg.activation_token.take() { - if let Err(err) = output - .send(Message::Cosmic(cosmic::Message::Activate(token))) - .await - { - tracing::error!(?err, "Failed to send message"); - } - } - if let Err(err) = output.send(Message::DbusActivation(msg)).await { - tracing::error!(?err, "Failed to send message"); - } - } - } - } else { - tracing::warn!("Failed to connect to dbus for single instance"); - } - - loop { - iced::futures::pending!(); - } - }, - ) + EMBEDDED_FONTS + .iter() + .for_each(move |font| font_system.load_font(Cow::Borrowed(font))); } diff --git a/src/app/settings.rs b/src/app/settings.rs index 10ed4dd3..5c903f09 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -3,10 +3,9 @@ //! Configure a new COSMIC application. -use crate::{font, Theme}; -#[cfg(feature = "wayland")] -use iced::Limits; +use crate::{Theme, font}; use iced_core::Font; +use iced_core::layout::Limits; /// Configure a new COSMIC application. #[allow(clippy::struct_excessive_bools)] @@ -17,11 +16,10 @@ pub struct Settings { pub(crate) antialiasing: bool, /// Autosize the window to fit its contents - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", target_os = "linux"))] pub(crate) autosize: bool, /// Set the application to not create a main window - #[cfg(feature = "wayland")] pub(crate) no_main_window: bool, /// Whether the window should have a border, a title bar, etc. or not. @@ -40,6 +38,9 @@ pub struct Settings { /// Default size of fonts. pub(crate) default_text_size: f32, + /// Set the default mmap threshold for malloc with mallopt. + pub(crate) default_mmap_threshold: Option, + /// Whether the window should be resizable or not. /// and the size of the window border which can be dragged for a resize pub(crate) resizable: Option, @@ -48,10 +49,9 @@ pub struct Settings { pub(crate) scale_factor: f32, /// Initial size of the window. - pub(crate) size: (u32, u32), + pub(crate) size: iced::Size, /// Limitations of the window size - #[cfg(feature = "wayland")] pub(crate) size_limits: Limits, /// The theme to apply to the application. @@ -60,8 +60,11 @@ pub struct Settings { /// Whether the window should be transparent. pub(crate) transparent: bool, - /// Whether the application should exit when there are no open windows + /// Whether the application window should close when the exit button is pressed pub(crate) exit_on_close: bool, + + /// Whether the application should act as a daemon + pub(crate) is_daemon: bool, } impl Settings { @@ -77,26 +80,26 @@ impl Default for Settings { fn default() -> Self { Self { antialiasing: true, - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", target_os = "linux"))] autosize: false, - #[cfg(feature = "wayland")] no_main_window: false, client_decorations: true, debug: false, - default_font: font::FONT, - default_icon_theme: Some(String::from("Cosmic")), + default_font: font::default(), + default_icon_theme: None, default_text_size: 14.0, + default_mmap_threshold: Some(128 * 1024), resizable: Some(8.0), scale_factor: std::env::var("COSMIC_SCALE") .ok() .and_then(|scale| scale.parse::().ok()) .unwrap_or(1.0), - size: (1024, 768), - #[cfg(feature = "wayland")] + size: iced::Size::new(1024.0, 768.0), size_limits: Limits::NONE.min_height(1.0).min_width(1.0), theme: crate::theme::system_preference(), - transparent: false, + transparent: true, exit_on_close: true, + is_daemon: true, } } } diff --git a/src/applet/column.rs b/src/applet/column.rs new file mode 100644 index 00000000..9657b566 --- /dev/null +++ b/src/applet/column.rs @@ -0,0 +1,517 @@ +//! Distribute content vertically. +use crate::iced; +use iced::core::alignment::{self, Alignment}; +use iced::core::event::{self, Event}; +use iced::core::layout; +use iced::core::mouse; +use iced::core::overlay; +use iced::core::renderer; +use iced::core::widget::{Operation, Tree}; +use iced::core::{ + Clipboard, Element, Layout, Length, Padding, Pixels, Rectangle, Shell, Size, Vector, Widget, + widget, +}; + +/// A container that distributes its contents vertically. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::{button, column}; +/// +/// #[derive(Debug, Clone)] +/// enum Message { +/// // ... +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// column![ +/// "I am on top!", +/// button("I am in the center!"), +/// "I am below.", +/// ].into() +/// } +/// ``` +#[allow(missing_debug_implementations)] +#[must_use] +pub struct Column<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { + spacing: f32, + padding: Padding, + width: Length, + height: Length, + max_width: f32, + align: Alignment, + clip: bool, + children: Vec>, +} + +impl<'a, Message, Theme, Renderer> Column<'a, Message, Theme, Renderer> +where + Renderer: iced::core::Renderer, +{ + /// Creates an empty [`Column`]. + pub fn new() -> Self { + Self::from_vec(Vec::new()) + } + + /// Creates a [`Column`] with the given capacity. + pub fn with_capacity(capacity: usize) -> Self { + Self::from_vec(Vec::with_capacity(capacity)) + } + + /// Creates a [`Column`] with the given elements. + pub fn with_children( + children: impl IntoIterator>, + ) -> Self { + let iterator = children.into_iter(); + + Self::with_capacity(iterator.size_hint().0).extend(iterator) + } + + /// Creates a [`Column`] from an already allocated [`Vec`]. + /// + /// Keep in mind that the [`Column`] will not inspect the [`Vec`], which means + /// it won't automatically adapt to the sizing strategy of its contents. + /// + /// If any of the children have a [`Length::Fill`] strategy, you will need to + /// call [`Column::width`] or [`Column::height`] accordingly. + pub fn from_vec(children: Vec>) -> Self { + Self { + spacing: 0.0, + padding: Padding::ZERO, + width: Length::Shrink, + height: Length::Shrink, + max_width: f32::INFINITY, + align: Alignment::Start, + clip: false, + children, + } + } + + /// Sets the vertical spacing _between_ elements. + /// + /// Custom margins per element do not exist in iced. You should use this + /// method instead! While less flexible, it helps you keep spacing between + /// elements consistent. + pub fn spacing(mut self, amount: impl Into) -> Self { + self.spacing = amount.into().0; + self + } + + /// Sets the [`Padding`] of the [`Column`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the width of the [`Column`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Column`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the maximum width of the [`Column`]. + pub fn max_width(mut self, max_width: impl Into) -> Self { + self.max_width = max_width.into().0; + self + } + + /// Sets the horizontal alignment of the contents of the [`Column`] . + pub fn align_x(mut self, align: impl Into) -> Self { + self.align = Alignment::from(align.into()); + self + } + + /// Sets whether the contents of the [`Column`] should be clipped on + /// overflow. + pub fn clip(mut self, clip: bool) -> Self { + self.clip = clip; + self + } + + /// Adds an element to the [`Column`]. + pub fn push(mut self, child: impl Into>) -> Self { + let child = child.into(); + let child_size = child.as_widget().size_hint(); + + self.width = self.width.enclose(child_size.width); + self.height = self.height.enclose(child_size.height); + + self.children.push(child); + self + } + + /// Adds an element to the [`Column`], if `Some`. + #[must_use] + pub fn push_maybe( + self, + child: Option>>, + ) -> Self { + if let Some(child) = child { + self.push(child) + } else { + self + } + } + + /// Extends the [`Column`] with the given children. + pub fn extend( + self, + children: impl IntoIterator>, + ) -> Self { + children.into_iter().fold(self, Self::push) + } +} + +impl Default for Column<'_, Message, Renderer> +where + Renderer: iced::core::Renderer, +{ + fn default() -> Self { + Self::new() + } +} + +impl<'a, Message, Theme, Renderer: iced::core::Renderer> + FromIterator> for Column<'a, Message, Theme, Renderer> +{ + fn from_iter>>(iter: T) -> Self { + Self::with_children(iter) + } +} + +impl Widget + for Column<'_, Message, Theme, Renderer> +where + Renderer: iced::core::Renderer, +{ + fn children(&self) -> Vec { + self.children.iter().map(Tree::new).collect() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::default()) + } + + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + + fn diff(&mut self, tree: &mut Tree) { + tree.diff_children(self.children.as_mut_slice()); + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let limits = limits.max_width(self.max_width); + + layout::flex::resolve( + layout::flex::Axis::Vertical, + renderer, + &limits, + self.width, + self.height, + self.padding, + self.spacing, + self.align, + &mut self.children, + &mut tree.children, + ) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { + self.children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + .for_each(|((child, state), c_layout)| { + child.as_widget_mut().operate( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + operation, + ); + }); + }); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let my_state = tree.state.downcast_mut::(); + + if let Some(hovered) = my_state.hovered { + let child_layout = layout.children().nth(hovered); + if let Some(child_layout) = child_layout + && cursor.is_over(child_layout.bounds()) + { + // if mouse event, we can skip checking other children + if let Event::Mouse(e) = &event { + if !matches!( + e, + mouse::Event::CursorLeft | mouse::Event::ButtonReleased { .. } + ) { + return self.children[hovered].as_widget_mut().update( + &mut tree.children[hovered], + event, + child_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + } else if let Event::Touch(t) = &event { + if !matches!( + t, + iced::core::touch::Event::FingerLifted { .. } + | iced::core::touch::Event::FingerLost { .. } + ) { + return self.children[hovered].as_widget_mut().update( + &mut tree.children[hovered], + event, + child_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + } + } else { + my_state.hovered = None; + } + } + + for (((i, child), state), c_layout) in self + .children + .iter_mut() + .enumerate() + .zip(&mut tree.children) + .zip(layout.children()) + { + let mut cursor_virtual = cursor; + if matches!( + event, + Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorEntered) + | Event::Touch( + iced_core::touch::Event::FingerMoved { .. } + | iced_core::touch::Event::FingerPressed { .. } + ) + ) && cursor.is_over(c_layout.bounds()) + { + my_state.hovered = Some(i); + return child.as_widget_mut().update( + state, + &event, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor_virtual, + renderer, + clipboard, + shell, + viewport, + ); + } else if my_state.hovered.is_some_and(|h| i != h) { + cursor_virtual = mouse::Cursor::Unavailable; + } + + child.as_widget_mut().update( + state, + &event, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor_virtual, + renderer, + clipboard, + shell, + viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .map(|((child, state), c_layout)| { + child.as_widget().mouse_interaction( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + viewport, + renderer, + ) + }) + .max() + .unwrap_or_default() + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if let Some(clipped_viewport) = layout.bounds().intersection(viewport) { + let my_state = tree.state.downcast_ref::(); + + let viewport = if self.clip { + &clipped_viewport + } else { + viewport + }; + + for (i, ((child, state), c_layout)) in self + .children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .filter(|(_, layout)| layout.bounds().intersects(viewport)) + .enumerate() + { + child.as_widget().draw( + state, + renderer, + theme, + style, + c_layout.with_virtual_offset(layout.virtual_offset()), + if my_state.hovered.is_some_and(|h| i == h) { + cursor + } else { + mouse::Cursor::Unavailable + }, + viewport, + ); + } + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + overlay::from_children( + &mut self.children, + tree, + layout, + renderer, + viewport, + translation, + ) + } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: Layout<'_>, + state: &Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + use iced_accessibility::A11yTree; + A11yTree::join( + self.children + .iter() + .zip(layout.children()) + .zip(state.children.iter()) + .map(|((c, c_layout), state)| { + c.as_widget().a11y_nodes( + c_layout.with_virtual_offset(layout.virtual_offset()), + state, + cursor, + ) + }), + ) + } + + fn drag_destinations( + &self, + state: &Tree, + layout: Layout<'_>, + renderer: &Renderer, + dnd_rectangles: &mut iced::core::clipboard::DndDestinationRectangles, + ) { + for ((e, c_layout), state) in self + .children + .iter() + .zip(layout.children()) + .zip(state.children.iter()) + { + e.as_widget().drag_destinations( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + dnd_rectangles, + ); + } + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: iced::core::Renderer + 'a, +{ + fn from(column: Column<'a, Message, Theme, Renderer>) -> Self { + Self::new(column) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct State { + hovered: Option, +} diff --git a/src/applet/mod.rs b/src/applet/mod.rs index 04c87b2a..48721e1c 100644 --- a/src/applet/mod.rs +++ b/src/applet/mod.rs @@ -1,46 +1,95 @@ #[cfg(feature = "applet-token")] pub mod token; +use crate::app::{BootData, BootDataInner, cosmic}; use crate::{ - app::Core, + Application, Element, Renderer, + app::iced_settings, cctk::sctk, - iced::{ + theme::{self, Button, THEME, system_dark, system_light}, + widget::{ self, - alignment::{Horizontal, Vertical}, - widget::Container, - window, Color, Length, Limits, Rectangle, + autosize::{self, Autosize, autosize}, + column::Column, + layer_container, + row::Row, + space::horizontal, + space::vertical, }, - iced_style, iced_widget, - theme::{self, Button, THEME}, - widget, Application, Element, Renderer, }; + pub use cosmic_panel_config; use cosmic_panel_config::{CosmicPanelBackground, PanelAnchor, PanelSize}; -use iced_core::Padding; -use iced_style::container::Appearance; -use iced_widget::runtime::command::platform_specific::wayland::popup::{ - SctkPopupSettings, SctkPositioner, +use iced::{ + self, Color, Length, Limits, Rectangle, + alignment::{Alignment, Horizontal, Vertical}, + widget::Container, + window, }; +use iced_core::{Padding, Shadow}; +use iced_runtime::platform_specific::wayland::popup::{SctkPopupSettings, SctkPositioner}; +use iced_widget::Text; use sctk::reexports::protocols::xdg::shell::client::xdg_positioner::{Anchor, Gravity}; -use std::rc::Rc; +use std::cell::RefCell; +use std::{borrow::Cow, num::NonZeroU32, rc::Rc, sync::LazyLock, time::Duration}; +use tracing::info; -use crate::app::cosmic; +pub mod column; +pub mod row; -const APPLET_PADDING: u32 = 8; +static AUTOSIZE_ID: LazyLock = + LazyLock::new(|| iced::id::Id::new("cosmic-applet-autosize")); +static AUTOSIZE_MAIN_ID: LazyLock = + LazyLock::new(|| iced::id::Id::new("cosmic-applet-autosize-main")); +static TOOLTIP_ID: LazyLock = LazyLock::new(|| iced::id::Id::new("subsurface")); +pub(crate) static TOOLTIP_WINDOW_ID: LazyLock = LazyLock::new(window::Id::unique); #[derive(Debug, Clone)] pub struct Context { pub size: Size, pub anchor: PanelAnchor, + pub spacing: u32, pub background: CosmicPanelBackground, pub output_name: String, + pub panel_type: PanelType, + /// Includes the configured size of the window. + /// This can be used by apples to handle overflow themselves. + pub suggested_bounds: Option, + /// Ratio of overlap for applet padding. + pub padding_overlap: f32, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Size { - PanelSize(PanelSize), // (width, height) Hardcoded((u16, u16)), + PanelSize(PanelSize), +} +#[derive(Clone, Debug, PartialEq)] +pub enum PanelType { + Panel, + Dock, + Other(String), +} + +impl ToString for PanelType { + fn to_string(&self) -> String { + match self { + Self::Panel => "Panel".to_string(), + Self::Dock => "Dock".to_string(), + Self::Other(other) => other.clone(), + } + } +} + +impl From for PanelType { + fn from(value: String) -> Self { + match value.as_str() { + "Panel" => PanelType::Panel, + "Dock" => PanelType::Dock, + _ => PanelType::Other(value), + } + } } impl Default for Context { @@ -56,90 +105,267 @@ impl Default for Context { .ok() .and_then(|size| ron::from_str(size.as_str()).ok()) .unwrap_or(PanelAnchor::Top), + spacing: std::env::var("COSMIC_PANEL_SPACING") + .ok() + .and_then(|size| ron::from_str(size.as_str()).ok()) + .unwrap_or(4), background: std::env::var("COSMIC_PANEL_BACKGROUND") .ok() .and_then(|size| ron::from_str(size.as_str()).ok()) .unwrap_or(CosmicPanelBackground::ThemeDefault), output_name: std::env::var("COSMIC_PANEL_OUTPUT").unwrap_or_default(), + panel_type: PanelType::from(std::env::var("COSMIC_PANEL_NAME").unwrap_or_default()), + padding_overlap: str::parse( + &std::env::var("COSMIC_PANEL_PADDING_OVERLAP").unwrap_or_default(), + ) + .unwrap_or(0.0), + suggested_bounds: None, } } } impl Context { #[must_use] - pub fn suggested_size(&self) -> (u16, u16) { + pub fn suggested_size(&self, is_symbolic: bool) -> (u16, u16) { match &self.size { - Size::PanelSize(size) => match size { - PanelSize::XL => (64, 64), - PanelSize::L => (36, 36), - PanelSize::M => (24, 24), - PanelSize::S => (16, 16), - PanelSize::XS => (12, 12), - }, + Size::PanelSize(size) => { + let s = size.get_applet_icon_size(is_symbolic) as u16; + (s, s) + } Size::Hardcoded((width, height)) => (*width, *height), } } + #[must_use] + pub fn suggested_window_size(&self) -> (NonZeroU32, NonZeroU32) { + let suggested = self.suggested_size(true); + let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true); + let (horizontal_padding, vertical_padding) = if self.is_horizontal() { + (applet_padding_major_axis, applet_padding_minor_axis) + } else { + (applet_padding_minor_axis, applet_padding_major_axis) + }; + + let configured_width = self + .suggested_bounds + .as_ref() + .and_then(|c| NonZeroU32::new(c.width as u32)) // TODO: should this be physical size instead of logical? + .unwrap_or_else(|| { + NonZeroU32::new(suggested.0 as u32 + horizontal_padding as u32 * 2).unwrap() + }); + + let configured_height = self + .suggested_bounds + .as_ref() + .and_then(|c| NonZeroU32::new(c.height as u32)) + .unwrap_or_else(|| { + NonZeroU32::new(suggested.1 as u32 + vertical_padding as u32 * 2).unwrap() + }); + info!("{configured_height:?}"); + (configured_width, configured_height) + } + + #[must_use] + pub fn suggested_padding(&self, is_symbolic: bool) -> (u16, u16) { + match &self.size { + Size::PanelSize(size) => ( + size.get_applet_shrinkable_padding(is_symbolic), + size.get_applet_padding(is_symbolic), + ), + Size::Hardcoded(_) => (12, 8), + } + } + // Set the default window size. Helper for application init with hardcoded size. pub fn window_size(&mut self, width: u16, height: u16) { self.size = Size::Hardcoded((width, height)); } - #[must_use] #[allow(clippy::cast_precision_loss)] pub fn window_settings(&self) -> crate::app::Settings { - let (width, height) = self.suggested_size(); - let width = u32::from(width); - let height = u32::from(height); + let (width, height) = self.suggested_size(true); + let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true); + let (horizontal_padding, vertical_padding) = if self.is_horizontal() { + (applet_padding_major_axis, applet_padding_minor_axis) + } else { + (applet_padding_minor_axis, applet_padding_major_axis) + }; + + let width = f32::from(width) + horizontal_padding as f32 * 2.; + let height = f32::from(height) + vertical_padding as f32 * 2.; let mut settings = crate::app::Settings::default() - .size((width + APPLET_PADDING * 2, height + APPLET_PADDING * 2)) - .size_limits( - Limits::NONE - .min_height(height as f32 + APPLET_PADDING as f32 * 2.0) - .max_height(height as f32 + APPLET_PADDING as f32 * 2.0) - .min_width(width as f32 + APPLET_PADDING as f32 * 2.0) - .max_width(width as f32 + APPLET_PADDING as f32 * 2.0), - ) + .size(iced_core::Size::new(width, height)) + .size_limits(Limits::NONE.min_height(height).min_width(width)) .resizable(None) - .default_text_size(18.0) - .default_font(crate::font::FONT) + .default_text_size(14.0) + .default_font(crate::font::default()) .transparent(true); if let Some(theme) = self.theme() { settings = settings.theme(theme); } + settings.exit_on_close = true; settings } #[must_use] - pub fn icon_button<'a, Message: 'static>( + pub fn is_horizontal(&self) -> bool { + matches!(self.anchor, PanelAnchor::Top | PanelAnchor::Bottom) + } + + pub fn icon_button_from_handle<'a, Message: Clone + 'static>( + &self, + icon: widget::icon::Handle, + ) -> crate::widget::Button<'a, Message> { + let suggested = self.suggested_size(icon.symbolic); + let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true); + let (horizontal_padding, vertical_padding) = if self.is_horizontal() { + (applet_padding_major_axis, applet_padding_minor_axis) + } else { + (applet_padding_minor_axis, applet_padding_major_axis) + }; + let symbolic = icon.symbolic; + let icon = widget::icon(icon) + .class(if symbolic { + theme::Svg::Custom(Rc::new(|theme| iced_widget::svg::Style { + color: Some(theme.cosmic().background.on.into()), + })) + } else { + theme::Svg::default() + }) + .width(Length::Fixed(suggested.0 as f32)) + .height(Length::Fixed(suggested.1 as f32)); + self.button_from_element(icon, symbolic) + } + + pub fn button_from_element<'a, Message: Clone + 'static>( + &self, + content: impl Into>, + use_symbolic_size: bool, + ) -> crate::widget::Button<'a, Message> { + let suggested = self.suggested_size(use_symbolic_size); + let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true); + let (horizontal_padding, vertical_padding) = if self.is_horizontal() { + (applet_padding_major_axis, applet_padding_minor_axis) + } else { + (applet_padding_minor_axis, applet_padding_major_axis) + }; + + crate::widget::button::custom(layer_container(content).center(Length::Fill)) + .width(Length::Fixed((suggested.0 + 2 * horizontal_padding) as f32)) + .height(Length::Fixed((suggested.1 + 2 * vertical_padding) as f32)) + .class(Button::AppletIcon) + } + + pub fn text_button<'a, Message: Clone + 'static>( + &self, + text: impl Into>, + message: Message, + ) -> crate::widget::Button<'a, Message> { + let text = text.into(); + let suggested = self.suggested_size(true); + + let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true); + let (horizontal_padding, vertical_padding) = if self.is_horizontal() { + (applet_padding_major_axis, applet_padding_minor_axis) + } else { + (applet_padding_minor_axis, applet_padding_major_axis) + }; + crate::widget::button::custom( + layer_container( + Text::from(text) + .height(Length::Fill) + .align_y(Alignment::Center), + ) + .center_y(Length::Fixed(f32::from(suggested.1 + 2 * vertical_padding))), + ) + .on_press_down(message) + .padding([0, horizontal_padding]) + .class(crate::theme::Button::AppletIcon) + } + + pub fn icon_button<'a, Message: Clone + 'static>( &self, icon_name: &'a str, - ) -> crate::widget::Button<'a, Message, Renderer> { - let suggested = self.suggested_size(); - crate::widget::button( - widget::icon( - widget::icon::from_name(icon_name) - .symbolic(true) - .size(self.suggested_size().0) - .into(), - ) - .style(theme::Svg::Custom(Rc::new(|theme| { - crate::iced_style::svg::Appearance { - color: Some(theme.cosmic().background.on.into()), - } - }))) - .width(Length::Fixed(suggested.0 as f32)) - .height(Length::Fixed(suggested.1 as f32)), + ) -> crate::widget::Button<'a, Message> { + let suggested_size = self.suggested_size(true); + self.icon_button_from_handle( + widget::icon::from_name(icon_name) + .symbolic(true) + .size(suggested_size.0) + .into(), ) - .padding(APPLET_PADDING as u16) - .style(Button::AppletIcon) + } + + pub fn applet_tooltip<'a, Message: 'static>( + &self, + content: impl Into>, + tooltip: impl Into>, + has_popup: bool, + on_surface_action: impl Fn(crate::surface::Action) -> Message + 'static, + parent_id: Option, + ) -> crate::widget::wayland::tooltip::widget::Tooltip<'a, Message, Message> { + let window_id = *TOOLTIP_WINDOW_ID; + let subsurface_id = TOOLTIP_ID.clone(); + let anchor = self.anchor; + let tooltip = tooltip.into(); + + crate::widget::wayland::tooltip::widget::Tooltip::<'a, Message, Message>::new( + content, + (!has_popup).then_some(move |bounds: Rectangle| { + let window_id = window_id; + let (popup_anchor, gravity) = match anchor { + PanelAnchor::Left => (Anchor::Right, Gravity::Right), + PanelAnchor::Right => (Anchor::Left, Gravity::Left), + PanelAnchor::Top => (Anchor::Bottom, Gravity::Bottom), + PanelAnchor::Bottom => (Anchor::Top, Gravity::Top), + }; + + SctkPopupSettings { + parent: parent_id.unwrap_or(window::Id::RESERVED), + id: window_id, + grab: false, + input_zone: Some(vec![Rectangle::new( + iced::Point::new(-1000., -1000.), + iced::Size::default(), + )]), + positioner: SctkPositioner { + size: None, + size_limits: Limits::NONE.min_width(1.).min_height(1.), + anchor_rect: Rectangle { + x: bounds.x.round() as i32, + y: bounds.y.round() as i32, + width: bounds.width.round() as i32, + height: bounds.height.round() as i32, + }, + anchor: popup_anchor, + gravity, + constraint_adjustment: 15, + offset: (0, 0), + reactive: true, + }, + parent_size: None, + close_with_children: true, + } + }), + move || { + Element::from(autosize::autosize( + layer_container(crate::widget::text(tooltip.clone())) + .layer(crate::cosmic_theme::Layer::Background) + .padding(4.), + subsurface_id.clone(), + )) + }, + on_surface_action(crate::surface::Action::DestroyPopup(window_id)), + on_surface_action, + ) + .delay(Duration::from_millis(100)) } // TODO popup container which tracks the size of itself and requests the popup to resize to match pub fn popup_container<'a, Message: 'static>( &self, content: impl Into>, - ) -> Container<'a, Message, Renderer> { + ) -> Autosize<'a, Message, crate::Theme, Renderer> { let (vertical_align, horizontal_align) = match self.anchor { PanelAnchor::Left => (Vertical::Center, Horizontal::Left), PanelAnchor::Right => (Vertical::Center, Horizontal::Right), @@ -147,24 +373,37 @@ impl Context { PanelAnchor::Bottom => (Vertical::Bottom, Horizontal::Center), }; - Container::::new(Container::::new(content).style( - theme::Container::custom(|theme| { - let cosmic = theme.cosmic(); - - Appearance { - text_color: Some(cosmic.background.on.into()), - background: Some(Color::from(cosmic.background.base).into()), - border_radius: 12.0.into(), - border_width: 1.0, - border_color: cosmic.background.divider.into(), - icon_color: Some(cosmic.background.on.into()), - } - }), - )) - .width(Length::Shrink) - .height(Length::Shrink) - .align_x(horizontal_align) - .align_y(vertical_align) + autosize( + Container::::new( + Container::::new(content).style(|theme| { + let cosmic = theme.cosmic(); + let corners = cosmic.corner_radii; + iced_widget::container::Style { + text_color: Some(cosmic.background.on.into()), + background: Some(Color::from(cosmic.background.base).into()), + border: iced::Border { + radius: corners.radius_m.into(), + width: 1.0, + color: cosmic.background.divider.into(), + }, + shadow: Shadow::default(), + icon_color: Some(cosmic.background.on.into()), + snap: true, + } + }), + ) + .height(Length::Shrink) + .align_x(horizontal_align) + .align_y(vertical_align), + AUTOSIZE_ID.clone(), + ) + .limits( + Limits::NONE + .min_height(1.) + .min_width(360.0) + .max_width(360.0) + .max_height(1000.0), + ) } #[must_use] @@ -177,8 +416,14 @@ impl Context { width_padding: Option, height_padding: Option, ) -> SctkPopupSettings { - let (width, height) = self.suggested_size(); - let pixel_offset = 8; + let (width, height) = self.suggested_size(true); + let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true); + let (horizontal_padding, vertical_padding) = if self.is_horizontal() { + (applet_padding_major_axis, applet_padding_minor_axis) + } else { + (applet_padding_minor_axis, applet_padding_major_axis) + }; + let pixel_offset = 4; let (offset, anchor, gravity) = match self.anchor { PanelAnchor::Left => ((pixel_offset, 0), Anchor::Right, Gravity::Right), PanelAnchor::Right => ((-pixel_offset, 0), Anchor::Left, Gravity::Left), @@ -196,26 +441,96 @@ impl Context { anchor_rect: Rectangle { x: 0, y: 0, - width: width_padding.unwrap_or(APPLET_PADDING as i32) * 2 + i32::from(width), - height: height_padding.unwrap_or(APPLET_PADDING as i32) * 2 + i32::from(height), + width: width_padding.unwrap_or(horizontal_padding as i32) * 2 + + i32::from(width), + height: height_padding.unwrap_or(vertical_padding as i32) * 2 + + i32::from(height), }, reactive: true, constraint_adjustment: 15, // slide_y, slide_x, flip_x, flip_y - ..Default::default() + size_limits: Limits::NONE + .min_height(1.0) + .min_width(360.0) + .max_width(360.0) + .max_height(1080.0), }, parent_size: None, grab: true, + close_with_children: false, + input_zone: None, } } + pub fn autosize_window<'a, Message: 'static>( + &self, + content: impl Into>, + ) -> Autosize<'a, Message, crate::Theme, crate::Renderer> { + let force_configured = matches!(&self.panel_type, PanelType::Other(n) if n.is_empty()); + let w = autosize(content, AUTOSIZE_MAIN_ID.clone()); + let mut limits = Limits::NONE; + let suggested_window_size = self.suggested_window_size(); + + if let Some(width) = self + .suggested_bounds + .as_ref() + .filter(|c| c.width as i32 > 0) + .map(|c| c.width) + { + limits = limits.width(width); + } + if let Some(height) = self + .suggested_bounds + .as_ref() + .filter(|c| c.height as i32 > 0) + .map(|c| c.height) + { + limits = limits.height(height); + } + + w.limits(limits) + } + #[must_use] pub fn theme(&self) -> Option { match self.background { - CosmicPanelBackground::Dark => Some(theme::Theme::dark()), - CosmicPanelBackground::Light => Some(theme::Theme::light()), + CosmicPanelBackground::Dark => { + let mut theme = system_dark(); + theme.theme_type.prefer_dark(Some(true)); + Some(theme) + } + CosmicPanelBackground::Light => { + let mut theme = system_light(); + theme.theme_type.prefer_dark(Some(false)); + Some(theme) + } _ => Some(theme::system_preference()), } } + + pub fn text<'a>(&self, msg: impl Into>) -> crate::widget::Text<'a, crate::Theme> { + let msg = msg.into(); + let t = match self.size { + Size::Hardcoded(_) => crate::widget::text, + Size::PanelSize(ref s) => { + let size = s.get_applet_icon_size_with_padding(false); + + let size_threshold_small = PanelSize::S.get_applet_icon_size_with_padding(false); + let size_threshold_medium = PanelSize::M.get_applet_icon_size_with_padding(false); + let size_threshold_large = PanelSize::L.get_applet_icon_size_with_padding(false); + + if size <= size_threshold_small { + crate::widget::text::body + } else if size <= size_threshold_medium { + crate::widget::text::title4 + } else if size <= size_threshold_large { + crate::widget::text::title3 + } else { + crate::widget::text::title2 + } + } + }; + t(msg).font(crate::font::default()) + } } /// Launch the application with the given settings. @@ -223,96 +538,93 @@ impl Context { /// # Errors /// /// Returns error on application failure. -pub fn run(autosize: bool, flags: App::Flags) -> iced::Result { +pub fn run(flags: App::Flags) -> iced::Result { let helper = Context::default(); + let mut settings = helper.window_settings(); - settings.autosize = autosize; - if autosize { - settings.size_limits = Limits::NONE; + settings.resizable = None; + + #[cfg(all(target_env = "gnu", not(target_os = "windows")))] + if let Some(threshold) = settings.default_mmap_threshold { + crate::malloc::limit_mmap_threshold(threshold); } - if let Some(icon_theme) = settings.default_icon_theme { - crate::icon_theme::set_default(icon_theme); + if let Some(icon_theme) = settings.default_icon_theme.as_ref() { + crate::icon_theme::set_default(icon_theme.clone()); } - let mut core = Core::default(); - core.window.show_window_menu = false; + THEME + .lock() + .unwrap() + .set_theme(settings.theme.theme_type.clone()); + + let (iced_settings, (mut core, flags), mut window_settings) = + iced_settings::(settings, flags); core.window.show_headerbar = false; core.window.sharp_corners = true; core.window.show_maximize = false; core.window.show_minimize = false; core.window.use_template = false; - core.debug = settings.debug; - core.set_scale_factor(settings.scale_factor); - core.set_window_width(settings.size.0); - core.set_window_height(settings.size.1); + window_settings.decorations = false; + window_settings.exit_on_close_request = true; + window_settings.resizable = false; + window_settings.resize_border = 0; - THEME.with(move |t| { - let mut cosmic_theme = t.borrow_mut(); - cosmic_theme.set_theme(settings.theme.theme_type); - }); + // TODO make multi-window not mandatory - let mut iced = iced::Settings::with_flags((core, flags)); - - iced.antialiasing = settings.antialiasing; - iced.default_font = settings.default_font; - iced.default_text_size = settings.default_text_size; - iced.id = Some(App::APP_ID.to_owned()); - - { - use iced::wayland::actions::window::SctkWindowSettings; - use iced_sctk::settings::InitialSurface; - iced.initial_surface = InitialSurface::XdgWindow(SctkWindowSettings { - app_id: Some(App::APP_ID.to_owned()), - autosize: settings.autosize, - client_decorations: settings.client_decorations, - resizable: settings.resizable, - size: settings.size, - size_limits: settings.size_limits, - title: None, - transparent: settings.transparent, - ..SctkWindowSettings::default() - }); + let no_main_window = core.main_window.is_none(); + if no_main_window { + // TODO still apply window settings? + // window_settings = window_settings.clone(); + core.main_window = Some(iced_core::window::Id::RESERVED); } + let mut app = iced::daemon( + BootData(Rc::new(RefCell::new(Some(BootDataInner:: { + flags, + core, + settings: window_settings, + })))), + cosmic::Cosmic::update, + cosmic::Cosmic::view, + ); - as iced::Application>::run(iced) + app.subscription(cosmic::Cosmic::subscription) + .style(cosmic::Cosmic::style) + .theme(cosmic::Cosmic::theme) + .settings(iced_settings) + .run() } #[must_use] -pub fn style() -> ::Style { - ::Style::Custom(Box::new(|theme| { - iced_style::application::Appearance { - background_color: Color::from_rgba(0.0, 0.0, 0.0, 0.0), - text_color: theme.cosmic().on_bg_color().into(), - icon_color: theme.cosmic().on_bg_color().into(), - } - })) +pub fn style() -> iced::theme::Style { + let theme = crate::theme::THEME.lock().unwrap(); + iced::theme::Style { + background_color: Color::from_rgba(0.0, 0.0, 0.0, 0.0), + text_color: theme.cosmic().on_bg_color().into(), + icon_color: theme.cosmic().on_bg_color().into(), + } } -pub fn menu_button<'a, Message>( +pub fn menu_button<'a, Message: Clone + 'a>( content: impl Into>, -) -> crate::widget::Button<'a, Message, crate::Renderer> { - crate::widget::Button::new(content) - .style(Button::AppletMenu) +) -> crate::widget::Button<'a, Message> { + crate::widget::button::custom(content) + .class(Button::AppletMenu) .padding(menu_control_padding()) .width(Length::Fill) } pub fn padded_control<'a, Message>( content: impl Into>, -) -> crate::widget::container::Container<'a, Message, crate::Renderer> { +) -> crate::widget::container::Container<'a, Message, crate::Theme, crate::Renderer> { crate::widget::container(content) .padding(menu_control_padding()) .width(Length::Fill) } pub fn menu_control_padding() -> Padding { - THEME - .with(|t| { - let t = t.borrow(); - let cosmic = t.cosmic(); - [cosmic.space_xxs(), cosmic.space_m()] - }) - .into() + let guard = THEME.lock().unwrap(); + let cosmic = guard.cosmic(); + [cosmic.space_xxs(), cosmic.space_m()].into() } diff --git a/src/applet/row.rs b/src/applet/row.rs new file mode 100644 index 00000000..a6745d1c --- /dev/null +++ b/src/applet/row.rs @@ -0,0 +1,507 @@ +//! Distribute content horizontally. +use crate::iced; +use iced::core::alignment::{self, Alignment}; +use iced::core::event::{self, Event}; +use iced::core::layout::{self, Layout}; +use iced::core::mouse; +use iced::core::overlay; +use iced::core::renderer; +use iced::core::widget::{Operation, Tree}; +use iced::core::{ + Clipboard, Element, Length, Padding, Pixels, Rectangle, Shell, Size, Vector, Widget, widget, +}; +use iced::touch; + +/// A container that distributes its contents horizontally. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::{button, row}; +/// +/// #[derive(Debug, Clone)] +/// enum Message { +/// // ... +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// row![ +/// "I am to the left!", +/// button("I am in the middle!"), +/// "I am to the right!", +/// ].into() +/// } +/// ``` +#[allow(missing_debug_implementations)] +#[must_use] +pub struct Row<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { + spacing: f32, + padding: Padding, + width: Length, + height: Length, + align: Alignment, + clip: bool, + children: Vec>, +} + +impl<'a, Message, Theme, Renderer> Row<'a, Message, Theme, Renderer> +where + Renderer: iced::core::Renderer, +{ + /// Creates an empty [`Row`]. + pub fn new() -> Self { + Self::from_vec(Vec::new()) + } + + /// Creates a [`Row`] with the given capacity. + pub fn with_capacity(capacity: usize) -> Self { + Self::from_vec(Vec::with_capacity(capacity)) + } + + /// Creates a [`Row`] with the given elements. + pub fn with_children( + children: impl IntoIterator>, + ) -> Self { + let iterator = children.into_iter(); + + Self::with_capacity(iterator.size_hint().0).extend(iterator) + } + + /// Creates a [`Row`] from an already allocated [`Vec`]. + /// + /// Keep in mind that the [`Row`] will not inspect the [`Vec`], which means + /// it won't automatically adapt to the sizing strategy of its contents. + /// + /// If any of the children have a [`Length::Fill`] strategy, you will need to + /// call [`Row::width`] or [`Row::height`] accordingly. + pub fn from_vec(children: Vec>) -> Self { + Self { + spacing: 0.0, + padding: Padding::ZERO, + width: Length::Shrink, + height: Length::Shrink, + align: Alignment::Start, + clip: false, + children, + } + } + + /// Sets the horizontal spacing _between_ elements. + /// + /// Custom margins per element do not exist in iced. You should use this + /// method instead! While less flexible, it helps you keep spacing between + /// elements consistent. + pub fn spacing(mut self, amount: impl Into) -> Self { + self.spacing = amount.into().0; + self + } + + /// Sets the [`Padding`] of the [`Row`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the width of the [`Row`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Row`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the vertical alignment of the contents of the [`Row`] . + pub fn align_y(mut self, align: impl Into) -> Self { + self.align = Alignment::from(align.into()); + self + } + + /// Sets whether the contents of the [`Row`] should be clipped on + /// overflow. + pub fn clip(mut self, clip: bool) -> Self { + self.clip = clip; + self + } + + /// Adds an [`Element`] to the [`Row`]. + pub fn push(mut self, child: impl Into>) -> Self { + let child = child.into(); + let child_size = child.as_widget().size_hint(); + + self.width = self.width.enclose(child_size.width); + self.height = self.height.enclose(child_size.height); + + self.children.push(child); + self + } + + /// Adds an element to the [`Row`], if `Some`. + pub fn push_maybe( + self, + child: Option>>, + ) -> Self { + if let Some(child) = child { + self.push(child) + } else { + self + } + } + + /// Extends the [`Row`] with the given children. + pub fn extend( + self, + children: impl IntoIterator>, + ) -> Self { + children.into_iter().fold(self, Self::push) + } +} + +impl<'a, Message, Renderer> Default for Row<'a, Message, Renderer> +where + Renderer: iced::core::Renderer, +{ + fn default() -> Self { + Self::new() + } +} + +impl<'a, Message, Theme, Renderer: iced::core::Renderer> + FromIterator> for Row<'a, Message, Theme, Renderer> +{ + fn from_iter>>(iter: T) -> Self { + Self::with_children(iter) + } +} + +impl Widget + for Row<'_, Message, Theme, Renderer> +where + Renderer: iced::core::Renderer, +{ + fn children(&self) -> Vec { + self.children.iter().map(Tree::new).collect() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::default()) + } + + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + + fn diff(&mut self, tree: &mut Tree) { + tree.diff_children(&mut self.children); + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::flex::resolve( + layout::flex::Axis::Horizontal, + renderer, + limits, + self.width, + self.height, + self.padding, + self.spacing, + self.align, + &mut self.children, + &mut tree.children, + ) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { + self.children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + .for_each(|((child, state), c_layout)| { + child.as_widget_mut().operate( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + operation, + ); + }); + }); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let my_state = tree.state.downcast_mut::(); + + if let Some(hovered) = my_state.hovered { + let child_layout = layout.children().nth(hovered); + if let Some(child_layout) = child_layout + && cursor.is_over(child_layout.bounds()) + { + // if mouse event, we can skip checking other children + if let Event::Mouse(e) = &event { + if !matches!( + e, + mouse::Event::CursorLeft | mouse::Event::ButtonReleased { .. } + ) { + return self.children[hovered].as_widget_mut().update( + &mut tree.children[hovered], + event, + child_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + } else if let Event::Touch(t) = &event { + if !matches!( + t, + iced::core::touch::Event::FingerLifted { .. } + | iced::core::touch::Event::FingerLost { .. } + ) { + return self.children[hovered].as_widget_mut().update( + &mut tree.children[hovered], + event, + child_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + } + } else { + my_state.hovered = None; + } + } + + for (((i, child), state), c_layout) in self + .children + .iter_mut() + .enumerate() + .zip(&mut tree.children) + .zip(layout.children()) + { + let mut cursor_virtual = cursor; + + if matches!( + event, + Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorEntered) + | Event::Touch( + iced_core::touch::Event::FingerMoved { .. } + | iced_core::touch::Event::FingerPressed { .. } + ) + ) && cursor.is_over(c_layout.bounds()) + { + my_state.hovered = Some(i); + return child.as_widget_mut().update( + state, + &event, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor_virtual, + renderer, + clipboard, + shell, + viewport, + ); + } else if my_state.hovered.is_some_and(|h| i != h) { + cursor_virtual = mouse::Cursor::Unavailable; + } + + child.as_widget_mut().update( + state, + &event, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor_virtual, + renderer, + clipboard, + shell, + viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .map(|((child, state), c_layout)| { + child.as_widget().mouse_interaction( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + viewport, + renderer, + ) + }) + .max() + .unwrap_or_default() + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if let Some(clipped_viewport) = layout.bounds().intersection(viewport) { + let my_state = tree.state.downcast_ref::(); + + let viewport = if self.clip { + &clipped_viewport + } else { + viewport + }; + + for (i, ((child, state), c_layout)) in self + .children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .filter(|(_, layout)| layout.bounds().intersects(viewport)) + .enumerate() + { + child.as_widget().draw( + state, + renderer, + theme, + style, + c_layout.with_virtual_offset(layout.virtual_offset()), + if my_state.hovered.is_some_and(|h| i == h) { + cursor + } else { + mouse::Cursor::Unavailable + }, + viewport, + ); + } + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + overlay::from_children( + &mut self.children, + tree, + layout, + renderer, + viewport, + translation, + ) + } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: Layout<'_>, + state: &Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + use iced_accessibility::A11yTree; + A11yTree::join( + self.children + .iter() + .zip(layout.children()) + .zip(state.children.iter()) + .map(|((c, c_layout), state)| { + c.as_widget().a11y_nodes( + c_layout.with_virtual_offset(layout.virtual_offset()), + state, + cursor, + ) + }), + ) + } + + fn drag_destinations( + &self, + state: &Tree, + layout: Layout<'_>, + renderer: &Renderer, + dnd_rectangles: &mut iced::core::clipboard::DndDestinationRectangles, + ) { + for ((e, c_layout), state) in self + .children + .iter() + .zip(layout.children()) + .zip(state.children.iter()) + { + e.as_widget().drag_destinations( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + dnd_rectangles, + ); + } + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: iced::core::Renderer + 'a, +{ + fn from(row: Row<'a, Message, Theme, Renderer>) -> Self { + Self::new(row) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct State { + hovered: Option, +} diff --git a/src/applet/token/subscription.rs b/src/applet/token/subscription.rs index c48e1250..07c528ea 100644 --- a/src/applet/token/subscription.rs +++ b/src/applet/token/subscription.rs @@ -1,11 +1,12 @@ use crate::iced; -use crate::iced::subscription; -use crate::iced_futures::futures; use cctk::sctk::reexports::calloop; use futures::{ - channel::mpsc::{unbounded, UnboundedReceiver}, SinkExt, StreamExt, + channel::mpsc::{UnboundedReceiver, unbounded}, }; +use iced::Subscription; +use iced_futures::futures; +use iced_futures::stream; use std::{fmt::Debug, hash::Hash, thread::JoinHandle}; use super::wayland_handler::wayland_handler; @@ -13,12 +14,14 @@ use super::wayland_handler::wayland_handler; pub fn activation_token_subscription( id: I, ) -> iced::Subscription { - subscription::channel(id, 50, move |mut output| async move { - let mut state = State::Ready; + Subscription::run_with(id, |_| { + stream::channel(50, move |mut output| async move { + let mut state = State::Ready; - loop { - state = start_listening(state, &mut output).await; - } + loop { + state = start_listening(state, &mut output).await; + } + }) }) } diff --git a/src/applet/token/wayland_handler.rs b/src/applet/token/wayland_handler.rs index cb795c8a..3db84fc4 100644 --- a/src/applet/token/wayland_handler.rs +++ b/src/applet/token/wayland_handler.rs @@ -21,7 +21,7 @@ use sctk::{ activation::{ActivationHandler, ActivationState}, registry::{ProvidesRegistryState, RegistryState}, }; -use wayland_client::{globals::registry_queue_init, Connection, QueueHandle}; +use wayland_client::{Connection, QueueHandle, globals::registry_queue_init}; struct AppData { exit: bool, @@ -162,8 +162,8 @@ pub(crate) fn wayland_handler( exit: false, tx, seat_state: SeatState::new(&globals, &qh), - queue_handle: qh.clone(), activation_state: ActivationState::bind::(&globals, &qh).ok(), + queue_handle: qh, registry_state, }; diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 00000000..1d6f635c --- /dev/null +++ b/src/command.rs @@ -0,0 +1,73 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +#[cfg(feature = "xdg-portal")] +use std::os::fd::AsFd; + +use iced::window; + +/// Initiates a window drag. +pub fn drag(id: window::Id) -> iced::Task> { + iced_runtime::window::drag(id) +} + +/// Maximizes the window. +pub fn maximize(id: window::Id, maximized: bool) -> iced::Task> { + iced_runtime::window::maximize(id, maximized) +} + +/// Minimizes the window. +pub fn minimize(id: window::Id) -> iced::Task> { + iced_runtime::window::minimize(id, true) +} + +/// Sets the title of a window. +#[allow(unused_variables, clippy::needless_pass_by_value)] +pub fn set_title(id: window::Id, title: String) -> iced::Task> { + iced::Task::none() +} + +#[cfg(feature = "winit")] +pub fn set_scaling_factor(factor: f32) -> iced::Task> { + iced::Task::done(crate::app::Action::ScaleFactor(factor)).map(crate::Action::Cosmic) +} + +#[cfg(feature = "winit")] +pub fn set_theme(theme: crate::Theme) -> iced::Task> { + iced::Task::done(crate::app::Action::AppThemeChange(theme)).map(crate::Action::Cosmic) +} + +/// Sets the window mode to windowed. +pub fn set_windowed(id: window::Id) -> iced::Task> { + iced_runtime::window::set_mode(id, window::Mode::Windowed) +} + +/// Toggles the windows' maximize state. +pub fn toggle_maximize(id: window::Id) -> iced::Task> { + iced_runtime::window::toggle_maximize(id) +} + +#[cfg(feature = "xdg-portal")] +pub fn file_transfer_send( + writeable: bool, + auto_stop: bool, + files: Vec, +) -> iced::Task> { + iced::Task::future(async move { + let file_transfer = ashpd::documents::FileTransfer::new().await?; + let key = file_transfer.start_transfer(writeable, auto_stop).await?; + file_transfer.add_files(&key, &files).await?; + Ok(key) + }) +} + +/// Receive the files offered over the xdg share portal using the `key`. +/// Returns a list of file paths. +#[cfg(feature = "xdg-portal")] +pub fn file_transfer_receive(key: String) -> iced::Task>> { + dbg!(&key); + iced::Task::future(async move { + let file_transfer = ashpd::documents::FileTransfer::new().await?; + file_transfer.retrieve_files(&key).await + }) +} diff --git a/src/command/mod.rs b/src/command/mod.rs deleted file mode 100644 index 032e5323..00000000 --- a/src/command/mod.rs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright 2023 System76 -// SPDX-License-Identifier: MPL-2.0 - -//! Create asynchronous actions to be performed in the background. - -#[cfg(feature = "wayland")] -use iced::window; -use iced::Command; -use iced_core::window::Mode; -#[cfg(feature = "wayland")] -use iced_runtime::command::platform_specific::wayland::window::Action as WindowAction; -#[cfg(feature = "wayland")] -use iced_runtime::command::platform_specific::wayland::Action as WaylandAction; -#[cfg(feature = "wayland")] -use iced_runtime::command::platform_specific::Action as PlatformAction; -use iced_runtime::command::Action; -use std::future::Future; - -/// Yields a command which contains a batch of commands. -pub fn batch(commands: impl IntoIterator>) -> Command { - Command::batch(commands) -} - -/// Yields a command which will run the future on the runtime executor. -pub fn future(future: impl Future + Send + 'static) -> Command { - Command::single(Action::Future(Box::pin(future))) -} - -/// Yields a command which will return a message. -pub fn message(message: M) -> Command { - future(async move { message }) -} - -/// Initiates a window drag. -#[cfg(feature = "wayland")] -pub fn drag() -> Command { - iced_sctk::commands::window::start_drag_window(window::Id(0)) -} - -/// Initiates a window drag. -#[cfg(not(feature = "wayland"))] -pub fn drag() -> Command { - iced_runtime::window::drag() -} - -/// Fullscreens the window. -#[cfg(feature = "wayland")] -pub fn fullscreen() -> Command { - iced_sctk::commands::window::set_mode_window(window::Id(0), Mode::Fullscreen) -} - -/// Fullscreens the window. -#[cfg(not(feature = "wayland"))] -pub fn fullscreen() -> Command { - iced_runtime::window::change_mode(Mode::Fullscreen) -} - -/// Minimizes the window. -#[cfg(feature = "wayland")] -pub fn minimize() -> Command { - iced_sctk::commands::window::set_mode_window(window::Id(0), Mode::Hidden) -} - -/// Minimizes the window. -#[cfg(not(feature = "wayland"))] -pub fn minimize() -> Command { - iced_runtime::window::minimize(true) -} - -/// Sets the title of a window. -#[cfg(feature = "wayland")] -pub fn set_title(title: String) -> Command { - window_action(WindowAction::Title { - id: window::Id(0), - title, - }) -} - -/// Sets the title of a window. -#[cfg(not(feature = "wayland"))] -#[allow(unused_variables, clippy::needless_pass_by_value)] -pub fn set_title(title: String) -> Command { - Command::none() -} - -/// Sets the window mode to windowed. -#[cfg(feature = "wayland")] -pub fn set_windowed() -> Command { - iced_sctk::commands::window::set_mode_window(window::Id(0), Mode::Windowed) -} - -/// Sets the window mode to windowed. -#[cfg(not(feature = "wayland"))] -pub fn set_windowed() -> Command { - iced_runtime::window::change_mode(Mode::Windowed) -} - -/// Toggles the windows' maximization state. -#[cfg(feature = "wayland")] -pub fn toggle_fullscreen() -> Command { - window_action(WindowAction::ToggleFullscreen { id: window::Id(0) }) -} - -/// Toggles the windows' maximization state. -#[cfg(not(feature = "wayland"))] -pub fn toggle_fullscreen() -> Command { - iced_runtime::window::toggle_maximize() -} - -/// Creates a command to apply an action to a window. -#[cfg(feature = "wayland")] -pub fn window_action(action: WindowAction) -> Command { - Command::single(Action::PlatformSpecific(PlatformAction::Wayland( - WaylandAction::Window(action), - ))) -} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 00000000..9807961c --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,177 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Configurations available to libcosmic applications. + +use crate::cosmic_theme::Density; +use cosmic_config::cosmic_config_derive::CosmicConfigEntry; +use cosmic_config::{Config, CosmicConfigEntry}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::sync::{LazyLock, RwLock}; + +/// ID for the `CosmicTk` config. +pub const ID: &str = "com.system76.CosmicTk"; + +const MONO_FAMILY_DEFAULT: &str = "Noto Sans Mono"; +const SANS_FAMILY_DEFAULT: &str = "Open Sans"; + +pub static COSMIC_TK: LazyLock> = LazyLock::new(|| { + RwLock::new( + CosmicTk::config() + .map(|c| { + CosmicTk::get_entry(&c).unwrap_or_else(|(errors, mode)| { + for why in errors.into_iter().filter(cosmic_config::Error::is_err) { + if let cosmic_config::Error::GetKey(_, err) = &why { + if err.kind() == std::io::ErrorKind::NotFound { + // No system default config installed; don't error + continue; + } + } + tracing::error!(?why, "CosmicTk config entry error"); + } + mode + }) + }) + .unwrap_or_default(), + ) +}); + +/// Apply the theme to other toolkits. +#[allow(clippy::missing_panics_doc)] +pub fn apply_theme_global() -> bool { + COSMIC_TK.read().unwrap().apply_theme_global +} + +/// Show minimize button in window header. +#[allow(clippy::missing_panics_doc)] +pub fn show_minimize() -> bool { + COSMIC_TK.read().unwrap().show_minimize +} + +/// Show maximize button in window header. +#[allow(clippy::missing_panics_doc)] +pub fn show_maximize() -> bool { + COSMIC_TK.read().unwrap().show_maximize +} + +/// Preferred icon theme. +#[allow(clippy::missing_panics_doc)] +pub fn icon_theme() -> String { + COSMIC_TK.read().unwrap().icon_theme.clone() +} + +/// Density of CSD/SSD header bars. +#[allow(clippy::missing_panics_doc)] +pub fn header_size() -> Density { + COSMIC_TK.read().unwrap().header_size +} + +/// Interface density. +#[allow(clippy::missing_panics_doc)] +pub fn interface_density() -> Density { + COSMIC_TK.read().unwrap().interface_density +} + +#[allow(clippy::missing_panics_doc)] +pub fn interface_font() -> FontConfig { + COSMIC_TK.read().unwrap().interface_font.clone() +} + +#[allow(clippy::missing_panics_doc)] +pub fn monospace_font() -> FontConfig { + COSMIC_TK.read().unwrap().monospace_font.clone() +} + +#[derive(Clone, CosmicConfigEntry, Debug, Eq, PartialEq)] +#[version = 1] +pub struct CosmicTk { + /// Apply the theme to other toolkits. + pub apply_theme_global: bool, + + /// Show minimize button in window header. + pub show_minimize: bool, + + /// Show maximize button in window header. + pub show_maximize: bool, + + /// Preferred icon theme. + pub icon_theme: String, + + /// Density of CSD/SSD header bars. + pub header_size: Density, + + /// Interface density. + pub interface_density: Density, + + /// Interface font family + pub interface_font: FontConfig, + + /// Mono font family + pub monospace_font: FontConfig, +} + +impl Default for CosmicTk { + fn default() -> Self { + Self { + apply_theme_global: false, + show_minimize: true, + show_maximize: true, + icon_theme: String::from("Cosmic"), + header_size: Density::Standard, + interface_density: Density::Standard, + interface_font: FontConfig { + family: SANS_FAMILY_DEFAULT.to_owned(), + weight: iced::font::Weight::Normal, + stretch: iced::font::Stretch::Normal, + style: iced::font::Style::Normal, + }, + monospace_font: FontConfig { + family: MONO_FAMILY_DEFAULT.to_owned(), + weight: iced::font::Weight::Normal, + stretch: iced::font::Stretch::Normal, + style: iced::font::Style::Normal, + }, + } + } +} + +impl CosmicTk { + #[inline] + pub fn config() -> Result { + Config::new(ID, Self::VERSION) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct FontConfig { + pub family: String, + pub weight: iced::font::Weight, + pub stretch: iced::font::Stretch, + pub style: iced::font::Style, +} + +impl From for iced::Font { + fn from(font: FontConfig) -> Self { + /// Stores static strings of the family names for `iced::Font` compatibility. + static FAMILY_MAP: LazyLock>> = + LazyLock::new(RwLock::default); + + let read_guard = FAMILY_MAP.read().unwrap(); + let name: Option<&'static str> = read_guard.get(font.family.as_str()).copied(); + drop(read_guard); + + let name = name.unwrap_or_else(|| { + let value: &'static str = font.family.clone().leak(); + FAMILY_MAP.write().unwrap().insert(value); + value + }); + + Self { + family: iced::font::Family::Name(name), + weight: font.weight, + stretch: font.stretch, + style: font.style, + } + } +} diff --git a/src/core.rs b/src/core.rs new file mode 100644 index 00000000..970a5351 --- /dev/null +++ b/src/core.rs @@ -0,0 +1,505 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use crate::widget::nav_bar; +use cosmic_config::CosmicConfigEntry; +use cosmic_theme::ThemeMode; +use iced::{Limits, Size, window}; +use iced_core::window::Id; +use palette::Srgba; +use slotmap::Key; + +use crate::Theme; + +/// Status of the nav bar and its panels. +#[derive(Clone)] +pub struct NavBar { + active: bool, + context_id: crate::widget::nav_bar::Id, + toggled: bool, + toggled_condensed: bool, +} + +/// COSMIC-specific settings for windows. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone)] +pub struct Window { + /// Label to display as header bar title. + pub header_title: String, + pub use_template: bool, + pub content_container: bool, + pub context_is_overlay: bool, + pub sharp_corners: bool, + pub show_context: bool, + pub show_headerbar: bool, + pub show_window_menu: bool, + pub show_close: bool, + pub show_maximize: bool, + pub show_minimize: bool, + pub is_maximized: bool, + height: f32, + width: f32, +} + +/// COSMIC-specific application settings +#[derive(Clone)] +pub struct Core { + /// Enables debug features in cosmic/iced. + pub debug: bool, + + /// Disables loading the icon theme from cosmic-config. + pub(super) icon_theme_override: bool, + + /// Whether the window is too small for the nav bar + main content. + is_condensed: bool, + + /// Enables built in keyboard navigation + pub(super) keyboard_nav: bool, + + /// Current status of the nav bar panel. + nav_bar: NavBar, + + /// Scaling factor used by the application + scale_factor: f32, + + /// Window focus state + pub(super) focused_window: Vec, + + pub(super) theme_sub_counter: u64, + /// Last known system theme + pub(super) system_theme: Theme, + + /// Configured theme mode + pub(super) system_theme_mode: ThemeMode, + + pub(super) portal_is_dark: Option, + + pub(super) portal_accent: Option, + + pub(super) portal_is_high_contrast: Option, + + pub(super) title: HashMap, + + pub window: Window, + + #[cfg(feature = "applet")] + pub applet: crate::applet::Context, + + #[cfg(feature = "single-instance")] + pub(crate) single_instance: bool, + + #[cfg(all(feature = "dbus-config", target_os = "linux"))] + pub(crate) settings_daemon: Option>, + + pub(crate) main_window: Option, + + pub(crate) exit_on_main_window_closed: bool, + + pub(crate) menu_bars: HashMap, + + #[cfg(all(feature = "wayland", target_os = "linux"))] + pub(crate) sync_window_border_radii_to_theme: bool, +} + +impl Default for Core { + fn default() -> Self { + Self { + debug: false, + icon_theme_override: false, + is_condensed: false, + keyboard_nav: true, + nav_bar: NavBar { + active: true, + context_id: crate::widget::nav_bar::Id::null(), + toggled: true, + toggled_condensed: false, + }, + scale_factor: 1.0, + title: HashMap::new(), + theme_sub_counter: 0, + system_theme: crate::theme::active(), + system_theme_mode: ThemeMode::config() + .map(|c| { + ThemeMode::get_entry(&c).unwrap_or_else(|(errors, mode)| { + for why in errors.into_iter().filter(cosmic_config::Error::is_err) { + tracing::error!(?why, "ThemeMode config entry error"); + } + mode + }) + }) + .unwrap_or_default(), + window: Window { + header_title: String::new(), + use_template: true, + content_container: true, + context_is_overlay: true, + sharp_corners: false, + show_context: false, + show_headerbar: true, + show_close: true, + show_maximize: true, + show_minimize: true, + show_window_menu: false, + is_maximized: false, + height: 0., + width: 0., + }, + focused_window: Vec::new(), + #[cfg(feature = "applet")] + applet: crate::applet::Context::default(), + #[cfg(feature = "single-instance")] + single_instance: false, + #[cfg(all(feature = "dbus-config", target_os = "linux"))] + settings_daemon: None, + portal_is_dark: None, + portal_accent: None, + portal_is_high_contrast: None, + main_window: None, + exit_on_main_window_closed: true, + menu_bars: HashMap::new(), + #[cfg(all(feature = "wayland", target_os = "linux"))] + sync_window_border_radii_to_theme: true, + } + } +} + +impl Core { + /// Whether the window is too small for the nav bar + main content. + #[must_use] + #[inline] + pub const fn is_condensed(&self) -> bool { + self.is_condensed + } + + /// The scaling factor used by the application. + #[must_use] + #[inline] + pub const fn scale_factor(&self) -> f32 { + self.scale_factor + } + + /// Enable or disable keyboard navigation + #[inline] + pub const fn set_keyboard_nav(&mut self, enabled: bool) { + self.keyboard_nav = enabled; + } + + /// Enable or disable keyboard navigation + #[must_use] + #[inline] + pub const fn keyboard_nav(&self) -> bool { + self.keyboard_nav + } + + /// Changes the scaling factor used by the application. + #[cold] + pub(crate) fn set_scale_factor(&mut self, factor: f32) { + self.scale_factor = factor; + self.is_condensed_update(); + } + + /// Set header bar title + #[inline] + pub fn set_header_title(&mut self, title: String) { + self.window.header_title = title; + } + + #[inline] + /// Whether to show or hide the main window's content. + pub(crate) fn show_content(&self) -> bool { + !self.is_condensed || !self.nav_bar.toggled_condensed + } + + #[allow(clippy::cast_precision_loss)] + /// Call this whenever the scaling factor or window width has changed. + fn is_condensed_update(&mut self) { + // Nav bar (280px) + padding (8px) + content (360px) + let mut breakpoint = 280.0 + 8.0 + 360.0; + //TODO: the app may return None from the context_drawer function even if show_context is true + if self.window.show_context && !self.window.context_is_overlay { + // Context drawer min width (344px) + padding (8px) + breakpoint += 344.0 + 8.0; + }; + self.is_condensed = (breakpoint * self.scale_factor) > self.window.width; + self.nav_bar_update(); + } + + #[inline] + fn condensed_conflict(&self) -> bool { + // There is a conflict if the view is condensed and both the nav bar and context drawer are open on the same layer + self.is_condensed + && self.nav_bar.toggled_condensed + && self.window.show_context + && !self.window.context_is_overlay + } + + #[inline] + pub(crate) fn context_width(&self, has_nav: bool) -> f32 { + let window_width = self.window.width / self.scale_factor; + + // Content width (360px) + padding (8px) + let mut reserved_width = 360.0 + 8.0; + if has_nav { + // Navbar width (280px) + padding (8px) + reserved_width += 280.0 + 8.0; + } + + #[allow(clippy::manual_clamp)] + // This logic is to ensure the context drawer does not take up too much of the content's space + // The minimum width is 344px and the maximum with is 480px + // We want to keep the content at least 360px until going down to the minimum width + (window_width - reserved_width).min(480.0).max(344.0) + } + + #[cold] + pub fn set_show_context(&mut self, show: bool) { + self.window.show_context = show; + self.is_condensed_update(); + // Ensure nav bar is closed if condensed view and context drawer is opened + if self.condensed_conflict() { + self.nav_bar.toggled_condensed = false; + self.is_condensed_update(); + } + } + + #[inline] + pub fn main_window_is(&self, id: iced::window::Id) -> bool { + self.main_window_id().is_some_and(|main_id| main_id == id) + } + + /// Whether the nav panel is visible or not + #[must_use] + #[inline] + pub const fn nav_bar_active(&self) -> bool { + self.nav_bar.active + } + + #[inline] + pub fn nav_bar_toggle(&mut self) { + self.nav_bar.toggled = !self.nav_bar.toggled; + self.nav_bar_set_toggled_condensed(self.nav_bar.toggled); + } + + #[inline] + pub fn nav_bar_toggle_condensed(&mut self) { + self.nav_bar_set_toggled_condensed(!self.nav_bar.toggled_condensed); + } + + #[inline] + pub(crate) const fn nav_bar_context(&self) -> nav_bar::Id { + self.nav_bar.context_id + } + + #[inline] + pub(crate) fn nav_bar_set_context(&mut self, id: nav_bar::Id) { + self.nav_bar.context_id = id; + } + + #[inline] + pub fn nav_bar_set_toggled(&mut self, toggled: bool) { + self.nav_bar.toggled = toggled; + self.nav_bar_set_toggled_condensed(self.nav_bar.toggled); + } + + #[cold] + pub(crate) fn nav_bar_set_toggled_condensed(&mut self, toggled: bool) { + self.nav_bar.toggled_condensed = toggled; + self.nav_bar_update(); + // Ensure context drawer is closed if condensed view and nav bar is opened + if self.condensed_conflict() { + self.window.show_context = false; + self.is_condensed_update(); + // Sync nav bar state if the view is no longer condensed after closing the context drawer + if !self.is_condensed { + self.nav_bar.toggled = toggled; + self.nav_bar_update(); + } + } + } + + #[inline] + pub(crate) fn nav_bar_update(&mut self) { + self.nav_bar.active = if self.is_condensed { + self.nav_bar.toggled_condensed + } else { + self.nav_bar.toggled + }; + } + + #[inline] + /// Set the height of the main window. + pub(crate) const fn set_window_height(&mut self, new_height: f32) { + self.window.height = new_height; + } + + #[inline] + /// Set the width of the main window. + pub(crate) fn set_window_width(&mut self, new_width: f32) { + self.window.width = new_width; + self.is_condensed_update(); + } + + #[inline] + /// Get the current system theme + pub const fn system_theme(&self) -> &Theme { + &self.system_theme + } + + #[inline] + #[must_use] + /// Get the current system theme mode + pub const fn system_theme_mode(&self) -> ThemeMode { + self.system_theme_mode + } + + pub fn watch_config< + T: CosmicConfigEntry + Send + Sync + Default + 'static + Clone + PartialEq, + >( + &self, + config_id: &'static str, + ) -> iced::Subscription> { + #[cfg(all(feature = "dbus-config", target_os = "linux"))] + if let Some(settings_daemon) = self.settings_daemon.as_ref() { + return cosmic_config::dbus::watcher_subscription( + settings_daemon.clone(), + config_id, + false, + ); + } + cosmic_config::config_subscription( + std::any::TypeId::of::(), + std::borrow::Cow::Borrowed(config_id), + T::VERSION, + ) + } + + pub fn watch_state< + T: CosmicConfigEntry + Send + Sync + Default + 'static + Clone + PartialEq, + >( + &self, + state_id: &'static str, + ) -> iced::Subscription> { + #[cfg(all(feature = "dbus-config", target_os = "linux"))] + if let Some(settings_daemon) = self.settings_daemon.as_ref() { + return cosmic_config::dbus::watcher_subscription( + settings_daemon.clone(), + state_id, + true, + ); + } + cosmic_config::config_subscription( + std::any::TypeId::of::(), + std::borrow::Cow::Borrowed(state_id), + T::VERSION, + ) + } + + /// Get the current focused window if it exists + #[must_use] + #[inline] + pub fn focused_window(&self) -> Option { + self.focused_window.last().copied() + } + + /// Get the current focus chain of windows + #[must_use] + #[inline] + pub fn focus_chain(&self) -> &[window::Id] { + &self.focused_window + } + + /// Whether the application should use a dark theme, according to the system + #[must_use] + #[inline] + pub fn system_is_dark(&self) -> bool { + self.portal_is_dark + .unwrap_or(self.system_theme_mode.is_dark) + } + + /// The [`Id`] of the main window + #[must_use] + #[inline] + pub fn main_window_id(&self) -> Option { + self.main_window.filter(|id| iced::window::Id::NONE != *id) + } + + /// Reset the tracked main window to a new value + #[inline] + pub fn set_main_window_id(&mut self, mut id: Option) -> Option { + std::mem::swap(&mut self.main_window, &mut id); + id + } + + #[cfg(feature = "winit")] + pub fn drag(&self, id: Option) -> crate::app::Task { + let Some(id) = id.or(self.main_window) else { + return iced::Task::none(); + }; + crate::command::drag(id) + } + + #[cfg(feature = "winit")] + pub fn maximize( + &self, + id: Option, + maximized: bool, + ) -> crate::app::Task { + let Some(id) = id.or(self.main_window) else { + return iced::Task::none(); + }; + crate::command::maximize(id, maximized) + } + + #[cfg(feature = "winit")] + pub fn minimize(&self, id: Option) -> crate::app::Task { + let Some(id) = id.or(self.main_window) else { + return iced::Task::none(); + }; + crate::command::minimize(id) + } + + #[cfg(feature = "winit")] + pub fn set_title( + &self, + id: Option, + title: String, + ) -> crate::app::Task { + let Some(id) = id.or(self.main_window) else { + return iced::Task::none(); + }; + crate::command::set_title(id, title) + } + + #[cfg(feature = "winit")] + pub fn set_windowed(&self, id: Option) -> crate::app::Task { + let Some(id) = id.or(self.main_window) else { + return iced::Task::none(); + }; + crate::command::set_windowed(id) + } + + #[cfg(feature = "winit")] + pub fn toggle_maximize( + &self, + id: Option, + ) -> crate::app::Task { + let Some(id) = id.or(self.main_window) else { + return iced::Task::none(); + }; + + crate::command::toggle_maximize(id) + } + + // TODO should we emit tasks setting the corner radius or unsetting it if this is changed? + #[cfg(all(feature = "wayland", target_os = "linux"))] + pub fn set_sync_window_border_radii_to_theme(&mut self, sync: bool) { + self.sync_window_border_radii_to_theme = sync; + } + + #[cfg(all(feature = "wayland", target_os = "linux"))] + pub fn sync_window_border_radii_to_theme(&self) -> bool { + self.sync_window_border_radii_to_theme + } +} diff --git a/src/dbus_activation.rs b/src/dbus_activation.rs new file mode 100644 index 00000000..99e2f9f0 --- /dev/null +++ b/src/dbus_activation.rs @@ -0,0 +1,231 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use { + crate::ApplicationExt, + iced::Subscription, + iced_futures::futures::{ + SinkExt, + channel::mpsc::{Receiver, Sender}, + }, + std::{any::TypeId, collections::HashMap}, + url::Url, + zbus::{interface, proxy, zvariant::Value}, +}; + +#[cold] +pub fn subscription() -> Subscription> { + use iced_futures::futures::StreamExt; + iced_futures::Subscription::run_with(TypeId::of::(), |_| { + iced::stream::channel( + 10, + move |mut output: Sender>| async move { + let mut single_instance: DbusActivation = DbusActivation::new(); + let mut rx = single_instance.rx(); + if let Ok(builder) = zbus::connection::Builder::session() { + let path: String = format!("/{}", App::APP_ID.replace('.', "/")); + if let Ok(conn) = builder.build().await { + // XXX Setup done this way seems to be more reliable. + // + // the docs for serve_at seem to imply it will replace the + // existing interface at the requested path, but it doesn't + // seem to work that way all the time. The docs for + // object_server().at() imply it won't replace the existing + // interface. + // + // request_name is used either way, with the builder or + // with the connection, but it must be done after the + // object server is setup. + if conn.object_server().at(path, single_instance).await != Ok(true) { + tracing::error!("Failed to serve dbus"); + std::process::exit(1); + } + if conn.request_name(App::APP_ID).await.is_err() { + tracing::error!("Failed to serve dbus"); + std::process::exit(1); + } + + output + .send(crate::Action::Cosmic(crate::app::Action::DbusConnection( + conn.clone(), + ))) + .await; + + #[cfg(feature = "smol")] + let handle = { + std::thread::spawn(move || { + let conn_clone = _conn.clone(); + + zbus::block_on(async move { + loop { + conn_clone.executor().tick().await; + } + }) + }) + }; + while let Some(mut msg) = rx.next().await { + if let Some(token) = msg.activation_token.take() { + if let Err(err) = output + .send(crate::Action::Cosmic(crate::app::Action::Activate( + token, + ))) + .await + { + tracing::error!(?err, "Failed to send message"); + } + } + if let Err(err) = output.send(crate::Action::DbusActivation(msg)).await + { + tracing::error!(?err, "Failed to send message"); + } + } + } + } else { + tracing::warn!("Failed to connect to dbus for single instance"); + } + + loop { + iced::futures::pending!(); + } + }, + ) + }) +} + +#[derive(Debug, Clone)] +pub struct Message> { + pub activation_token: Option, + pub desktop_startup_id: Option, + pub msg: Details, +} + +#[derive(Debug, Clone)] +pub enum Details> { + Activate, + Open { + url: Vec, + }, + /// action can be deserialized as Flags + ActivateAction { + action: Action, + args: Args, + }, +} + +#[derive(Debug, Default)] +pub struct DbusActivation(Option>); + +impl DbusActivation { + #[must_use] + #[inline] + pub fn new() -> Self { + Self(None) + } + + #[inline] + pub fn rx(&mut self) -> Receiver { + let (tx, rx) = iced_futures::futures::channel::mpsc::channel(10); + self.0 = Some(tx); + rx + } +} + +#[proxy(interface = "org.freedesktop.DbusActivation", assume_defaults = true)] +pub trait DbusActivationInterface { + /// Activate the application. + fn activate(&mut self, platform_data: HashMap<&str, Value<'_>>) -> zbus::Result<()>; + + /// Open the given URIs. + fn open( + &mut self, + uris: Vec<&str>, + platform_data: HashMap<&str, Value<'_>>, + ) -> zbus::Result<()>; + + /// Activate the given action. + fn activate_action( + &mut self, + action_name: &str, + parameter: Vec<&str>, + platform_data: HashMap<&str, Value<'_>>, + ) -> zbus::Result<()>; +} + +#[interface(name = "org.freedesktop.DbusActivation")] +impl DbusActivation { + #[cold] + async fn activate(&mut self, platform_data: HashMap<&str, Value<'_>>) { + if let Some(tx) = &mut self.0 { + let _ = tx + .send(Message { + activation_token: platform_data.get("activation-token").and_then(|t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }), + desktop_startup_id: platform_data.get("desktop-startup-id").and_then( + |t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }, + ), + msg: Details::Activate, + }) + .await; + } + } + + #[cold] + async fn open(&mut self, uris: Vec<&str>, platform_data: HashMap<&str, Value<'_>>) { + if let Some(tx) = &mut self.0 { + let _ = tx + .send(Message { + activation_token: platform_data.get("activation-token").and_then(|t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }), + desktop_startup_id: platform_data.get("desktop-startup-id").and_then( + |t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }, + ), + msg: Details::Open { + url: uris.iter().filter_map(|u| Url::parse(u).ok()).collect(), + }, + }) + .await; + } + } + + #[cold] + async fn activate_action( + &mut self, + action_name: &str, + parameter: Vec<&str>, + platform_data: HashMap<&str, Value<'_>>, + ) { + if let Some(tx) = &mut self.0 { + let _ = tx + .send(Message { + activation_token: platform_data.get("activation-token").and_then(|t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }), + desktop_startup_id: platform_data.get("desktop-startup-id").and_then( + |t| match t { + Value::Str(t) => Some(t.to_string()), + _ => None, + }, + ), + msg: Details::ActivateAction { + action: action_name.to_string(), + args: parameter + .iter() + .map(std::string::ToString::to_string) + .collect(), + }, + }) + .await; + } + } +} diff --git a/src/desktop.rs b/src/desktop.rs new file mode 100644 index 00000000..98ce7d4b --- /dev/null +++ b/src/desktop.rs @@ -0,0 +1,1142 @@ +#[cfg(not(windows))] +pub use freedesktop_desktop_entry as fde; +#[cfg(not(windows))] +pub use mime::Mime; +use std::path::{Path, PathBuf}; +#[cfg(not(windows))] +use std::{borrow::Cow, collections::HashSet, ffi::OsStr}; + +pub trait IconSourceExt { + fn as_cosmic_icon(&self) -> crate::widget::icon::Handle; +} + +#[cfg(not(windows))] +impl IconSourceExt for fde::IconSource { + fn as_cosmic_icon(&self) -> crate::widget::icon::Handle { + match self { + fde::IconSource::Name(name) => crate::widget::icon::from_name(name.as_str()) + .prefer_svg(true) + .size(128) + .fallback(Some(crate::widget::icon::IconFallback::Names(vec![ + "application-default".into(), + "application-x-executable".into(), + ]))) + .handle(), + fde::IconSource::Path(path) => crate::widget::icon::from_path(path.clone()), + } + } +} + +#[cfg(not(windows))] +#[derive(Debug, Clone, PartialEq)] +pub struct DesktopAction { + pub name: String, + pub exec: String, +} + +#[cfg(not(windows))] +#[derive(Debug, Clone, PartialEq, Default)] +pub struct DesktopEntryData { + pub id: String, + pub name: String, + pub wm_class: Option, + pub exec: Option, + pub icon: fde::IconSource, + pub path: Option, + pub categories: Vec, + pub desktop_actions: Vec, + pub mime_types: Vec, + pub prefers_dgpu: bool, + pub terminal: bool, +} + +#[cfg(not(windows))] +#[derive(Debug, Clone)] +pub struct DesktopEntryCache { + locales: Vec, + entries: Vec, +} + +#[cfg(not(windows))] +impl DesktopEntryCache { + pub fn new(locales: Vec) -> Self { + Self { + locales, + entries: Vec::new(), + } + } + + pub fn from_entries(locales: Vec, entries: Vec) -> Self { + Self { locales, entries } + } + + pub fn ensure_loaded(&mut self) { + if self.entries.is_empty() { + self.refresh(); + } + } + + pub fn refresh(&mut self) { + self.entries = fde::Iter::new(fde::default_paths()) + .filter_map(|p| fde::DesktopEntry::from_path(p, Some(&self.locales)).ok()) + .collect(); + } + + pub fn insert(&mut self, entry: fde::DesktopEntry) { + if self + .entries + .iter() + .any(|existing| existing.id() == entry.id()) + { + return; + } + + self.entries.push(entry); + } + + pub fn locales(&self) -> &[String] { + &self.locales + } + + pub fn entries(&self) -> &[fde::DesktopEntry] { + &self.entries + } + + pub fn entries_mut(&mut self) -> &mut [fde::DesktopEntry] { + &mut self.entries + } +} + +#[cfg(not(windows))] +impl Default for DesktopEntryCache { + fn default() -> Self { + Self::new(Vec::new()) + } +} + +#[cfg(not(windows))] +#[derive(Debug, Clone)] +pub struct DesktopLookupContext<'a> { + pub app_id: Cow<'a, str>, + pub identifier: Option>, + pub title: Option>, +} + +#[cfg(not(windows))] +impl<'a> DesktopLookupContext<'a> { + pub fn new(app_id: impl Into>) -> Self { + Self { + app_id: app_id.into(), + identifier: None, + title: None, + } + } + + pub fn with_identifier(mut self, identifier: impl Into>) -> Self { + self.identifier = Some(identifier.into()); + self + } + + pub fn with_title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } +} + +#[cfg(not(windows))] +#[derive(Debug, Clone)] +pub struct DesktopResolveOptions { + pub include_no_display: bool, + pub xdg_current_desktop: Option, +} + +#[cfg(not(windows))] +impl Default for DesktopResolveOptions { + fn default() -> Self { + Self { + include_no_display: false, + xdg_current_desktop: std::env::var("XDG_CURRENT_DESKTOP").ok(), + } + } +} + +#[cfg(not(windows))] +/// Resolve a DesktopEntry for a running toplevel, applying heuristics over +/// app_id, identifier, and title. Includes Proton/Wine handling: Proton can +/// open games as `steam_app_X` (often `steam_app_default`), and Wine windows +/// may use an `.exe` app_id. In those cases we match the localized name +/// against the toplevel title and, for Proton default, restrict matches to +/// entries with `Game` in Categories. +pub fn resolve_desktop_entry( + cache: &mut DesktopEntryCache, + context: &DesktopLookupContext<'_>, + options: &DesktopResolveOptions, +) -> fde::DesktopEntry { + let app_id = fde::unicase::Ascii::new(context.app_id.as_ref()); + + if let Some(entry) = fde::find_app_by_id(cache.entries(), app_id) { + return entry.clone(); + } + + cache.refresh(); + if let Some(entry) = fde::find_app_by_id(cache.entries(), app_id) { + return entry.clone(); + } + + let candidate_ids = candidate_desktop_ids(context); + + if let Some(entry) = try_match_cached(cache.entries(), &candidate_ids) { + return entry; + } + + if let Some(entry) = load_entry_via_app_ids( + cache, + &candidate_ids, + options.include_no_display, + options.xdg_current_desktop.as_deref(), + ) { + cache.insert(entry.clone()); + return entry; + } + + if let Some(entry) = match_startup_wm_class(cache.entries(), context) { + return entry; + } + + // Chromium/CRX heuristic: scan exec/wmclass/icon for a CRX id match. + if let Some(entry) = match_crx_id(cache.entries(), context) { + return entry; + } + + if let Some(entry) = match_exec_basename(cache.entries(), &candidate_ids) { + return entry; + } + + if let Some(entry) = proton_or_wine_fallback(cache, context) { + cache.insert(entry.clone()); + entry + } else { + let fallback = fallback_entry(context); + cache.insert(fallback.clone()); + fallback + } +} + +#[cfg(not(windows))] +fn try_match_cached( + entries: &[fde::DesktopEntry], + candidate_ids: &[String], +) -> Option { + candidate_ids.iter().find_map(|candidate| { + fde::find_app_by_id(entries, fde::unicase::Ascii::new(candidate.as_str())).cloned() + }) +} + +#[cfg(not(windows))] +fn load_entry_via_app_ids( + cache: &DesktopEntryCache, + candidate_ids: &[String], + include_no_display: bool, + xdg_current_desktop: Option<&str>, +) -> Option { + if candidate_ids.is_empty() { + return None; + } + + let candidate_refs: Vec<&str> = candidate_ids.iter().map(String::as_str).collect(); + let locales = cache.locales().to_vec(); + let iter_locales = locales.clone(); + + let desktop_iter = fde::Iter::new(fde::default_paths()) + .filter_map(move |path| fde::DesktopEntry::from_path(path, Some(&iter_locales)).ok()); + + let app_iter = load_applications_for_app_ids( + desktop_iter, + &locales, + candidate_refs, + false, + include_no_display, + xdg_current_desktop, + ); + + let locales_for_load = cache.locales().to_vec(); + for app in app_iter { + if let Some(path) = app.path { + if let Ok(entry) = fde::DesktopEntry::from_path(path, Some(&locales_for_load)) { + return Some(entry); + } + } + } + + None +} + +#[cfg(not(windows))] +fn match_startup_wm_class( + entries: &[fde::DesktopEntry], + context: &DesktopLookupContext<'_>, +) -> Option { + let mut candidates = Vec::new(); + candidates.push(context.app_id.as_ref()); + if let Some(identifier) = context.identifier.as_deref() { + candidates.push(identifier); + } + if let Some(title) = context.title.as_deref() { + candidates.push(title); + } + + for entry in entries { + let Some(wm_class) = entry.startup_wm_class() else { + continue; + }; + + if candidates + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(wm_class)) + { + return Some(entry.clone()); + } + } + + None +} + +#[cfg(not(windows))] +fn is_crx_id(candidate: &str) -> bool { + is_crx_bytes(candidate.as_bytes()) +} + +#[cfg(not(windows))] +fn is_crx_bytes(bytes: &[u8]) -> bool { + bytes.len() == 32 && bytes.iter().all(|b| matches!(b, b'a'..=b'p')) +} + +#[cfg(not(windows))] +pub fn extract_crx_id(value: &str) -> Option { + if let Some(rest) = value.strip_prefix("chrome-") { + if let Some(first) = rest.split(&['-', '_'][..]).next() { + if is_crx_id(first) { + return Some(first.to_string()); + } + } + } + if let Some(rest) = value.strip_prefix("crx_") { + let token = rest + .split(|c: char| !c.is_ascii_lowercase()) + .next() + .unwrap_or(rest); + if is_crx_id(token) { + return Some(token.to_string()); + } + } + if is_crx_id(value) { + return Some(value.to_string()); + } + + for window in value.as_bytes().windows(32) { + if is_crx_bytes(window) { + // SAFETY: `is_crx_bytes` guarantees the window is ASCII. + let slice = std::str::from_utf8(window).expect("ASCII window"); + return Some(slice.to_string()); + } + } + + None +} + +#[cfg(not(windows))] +fn match_crx_id( + entries: &[fde::DesktopEntry], + context: &DesktopLookupContext<'_>, +) -> Option { + let crx = extract_crx_id(context.app_id.as_ref()) + .or_else(|| context.identifier.as_deref().and_then(extract_crx_id))?; + + for entry in entries { + if let Some(exec) = entry.exec() { + if exec.contains(&format!("--app-id={}", crx)) { + return Some(entry.clone()); + } + } + if let Some(wm) = entry.startup_wm_class() { + if wm.eq_ignore_ascii_case(&format!("crx_{}", crx)) { + return Some(entry.clone()); + } + } + if let Some(icon) = entry.icon() { + if icon.contains(&crx) { + return Some(entry.clone()); + } + } + } + + None +} + +#[cfg(not(windows))] +fn match_exec_basename( + entries: &[fde::DesktopEntry], + candidate_ids: &[String], +) -> Option { + fn normalize_candidate(candidate: &str) -> String { + candidate + .trim_matches(|c: char| c == '"' || c == '\'') + .to_ascii_lowercase() + } + + let mut normalized: Vec = candidate_ids + .iter() + .map(|c| normalize_candidate(c)) + .collect(); + normalized.retain(|c| !c.is_empty()); + + for entry in entries { + let Some(exec) = entry.exec() else { + continue; + }; + + let command = exec + .split_whitespace() + .next() + .map(|token| token.trim_matches(|c: char| c == '"' || c == '\'')) + .filter(|token| !token.is_empty()); + + let Some(command) = command else { + continue; + }; + + let command = Path::new(command); + let basename = command + .file_stem() + .or_else(|| command.file_name()) + .and_then(|s| s.to_str()); + + let Some(basename) = basename else { + continue; + }; + + let basename_lower = basename.to_ascii_lowercase(); + if normalized + .iter() + .any(|candidate| candidate == &basename_lower) + { + return Some(entry.clone()); + } + } + + None +} + +#[cfg(not(windows))] +fn fallback_entry(context: &DesktopLookupContext<'_>) -> fde::DesktopEntry { + let mut entry = fde::DesktopEntry { + appid: context.app_id.to_string(), + groups: Default::default(), + path: Default::default(), + ubuntu_gettext_domain: None, + }; + + let name = context + .title + .as_ref() + .map_or_else(|| context.app_id.to_string(), |title| title.to_string()); + entry.add_desktop_entry("Name".to_string(), name); + entry +} + +#[cfg(not(windows))] +// proton opens games as steam_app_X, where X is either the steam appid or +// "default". Games with a steam appid can have a desktop entry generated +// elsewhere; this specifically handles non-steam games opened under Proton. +// In addition, try to match WINE entries whose app_id is the full name of +// the executable (including `.exe`). +fn proton_or_wine_fallback( + cache: &DesktopEntryCache, + context: &DesktopLookupContext<'_>, +) -> Option { + let app_id = context.app_id.as_ref(); + let is_proton_game = app_id == "steam_app_default"; + let is_wine_entry = std::path::Path::new(app_id) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")); + + if !is_proton_game && !is_wine_entry { + return None; + } + + let title = context.title.as_deref()?; + + for entry in cache.entries() { + let localized_name_matches = entry + .name(cache.locales()) + .is_some_and(|name| name == title); + + if !localized_name_matches { + continue; + } + + if is_proton_game && !entry.categories().unwrap_or_default().contains(&"Game") { + continue; + } + + return Some(entry.clone()); + } + + None +} + +#[cfg(not(windows))] +fn candidate_desktop_ids(context: &DesktopLookupContext<'_>) -> Vec { + fn push_candidate(seen: &mut HashSet, ordered: &mut Vec, candidate: &str) { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return; + } + + let key = trimmed.to_ascii_lowercase(); + if seen.insert(key) { + ordered.push(trimmed.to_string()); + } + } + + fn add_variants( + seen: &mut HashSet, + ordered: &mut Vec, + value: Option<&str>, + suffixes: &[&str], + ) { + let Some(value) = value else { + return; + }; + + let stripped_quotes = value.trim_matches(|c: char| c == '"' || c == '\''); + let trimmed = stripped_quotes.trim(); + if trimmed.is_empty() { + return; + } + + push_candidate(seen, ordered, trimmed); + if stripped_quotes != trimmed { + push_candidate(seen, ordered, stripped_quotes.trim()); + } + + for suffix in suffixes { + if trimmed.ends_with(suffix) { + let cut = &trimmed[..trimmed.len() - suffix.len()]; + push_candidate(seen, ordered, cut); + } + } + + if trimmed.contains('.') + && let Some(last) = trimmed.rsplit('.').next() + { + if last.len() >= 2 { + push_candidate(seen, ordered, last); + } + } + + if trimmed.contains('-') { + push_candidate(seen, ordered, &trimmed.replace('-', "_")); + } + if trimmed.contains('_') { + push_candidate(seen, ordered, &trimmed.replace('_', "-")); + } + + for token in + trimmed.split(|c: char| matches!(c, '.' | '-' | '_' | '@') || c.is_whitespace()) + { + if token.len() >= 2 && token != trimmed { + push_candidate(seen, ordered, token); + } + } + } + + const SUFFIXES: &[&str] = &[".desktop", ".Desktop", ".DESKTOP"]; + + let mut ordered = Vec::new(); + let mut seen = HashSet::new(); + + add_variants( + &mut seen, + &mut ordered, + Some(context.app_id.as_ref()), + SUFFIXES, + ); + add_variants( + &mut seen, + &mut ordered, + context.identifier.as_deref(), + SUFFIXES, + ); + add_variants(&mut seen, &mut ordered, context.title.as_deref(), &[]); + + // Chromium/Chrome PWA heuristics: favorites may store a short id like + // "chrome--Default" while the actual desktop id is + // "org.chromium.Chromium.flextop.chrome--Default" (Flatpak Chromium) + // or sometimes "org.chromium.Chromium.chrome--Default". Expand those + // candidates so we can match cached entries. + if let Some(app_id) = Some(context.app_id.as_ref()) { + if let Some(rest) = app_id.strip_prefix("chrome-") { + if rest.ends_with("-Default") { + let crx = rest.trim_end_matches("-Default"); + let variants = [ + format!("org.chromium.Chromium.flextop.chrome-{}-Default", crx), + format!("org.chromium.Chromium.chrome-{}-Default", crx), + ]; + for v in variants { + push_candidate(&mut seen, &mut ordered, &v); + } + } + } + if let Some(rest) = app_id.strip_prefix("crx_") { + // Older identifiers may be crx_; expand similarly + let crx = rest; + let variants = [ + format!("org.chromium.Chromium.flextop.chrome-{}-Default", crx), + format!("org.chromium.Chromium.chrome-{}-Default", crx), + ]; + for v in variants { + push_candidate(&mut seen, &mut ordered, &v); + } + } + } + + ordered +} + +#[cfg(not(windows))] +pub fn load_applications<'a>( + locales: &'a [String], + include_no_display: bool, + only_show_in: Option<&'a str>, +) -> impl Iterator + 'a { + fde::Iter::new(fde::default_paths()) + .filter_map(move |p| fde::DesktopEntry::from_path(p, Some(locales)).ok()) + .filter(move |de| { + (include_no_display || !de.no_display()) + && only_show_in.zip(de.only_show_in()).is_none_or( + |(xdg_current_desktop, only_show_in)| { + only_show_in.contains(&xdg_current_desktop) + }, + ) + && only_show_in.zip(de.not_show_in()).is_none_or( + |(xdg_current_desktop, not_show_in)| { + !not_show_in.contains(&xdg_current_desktop) + }, + ) + }) + .map(move |de| DesktopEntryData::from_desktop_entry(locales, de)) +} + +// Create an iterator which filters desktop entries by app IDs. +#[cfg(not(windows))] +#[auto_enums::auto_enum(Iterator)] +pub fn load_applications_for_app_ids<'a>( + iter: impl Iterator + 'a, + locales: &'a [String], + app_ids: Vec<&'a str>, + fill_missing_ones: bool, + include_no_display: bool, + only_show_in: Option<&'a str>, +) -> impl Iterator + 'a { + let app_ids = std::rc::Rc::new(std::cell::RefCell::new(app_ids)); + let app_ids_ = app_ids.clone(); + + let applications = iter + .filter(move |de| { + if !include_no_display && de.no_display() { + return false; + } + if only_show_in.zip(de.only_show_in()).is_some_and( + |(xdg_current_desktop, only_show_in)| !only_show_in.contains(&xdg_current_desktop), + ) { + return false; + } + if only_show_in.zip(de.not_show_in()).is_some_and( + |(xdg_current_desktop, not_show_in)| not_show_in.contains(&xdg_current_desktop), + ) { + return false; + } + + // Search by ID first + app_ids + .borrow() + .iter() + .position(|id| de.matches_id(fde::unicase::Ascii::new(*id))) + // Then fall back to search by name + .or_else(|| { + app_ids + .borrow() + .iter() + .position(|id| de.matches_name(fde::unicase::Ascii::new(*id))) + }) + // Remove the app ID if found + .map(|i| { + app_ids.borrow_mut().remove(i); + true + }) + .unwrap_or_default() + }) + .map(move |de| DesktopEntryData::from_desktop_entry(locales, de)); + + if fill_missing_ones { + applications.chain( + std::iter::once_with(move || { + std::mem::take(&mut *app_ids_.borrow_mut()) + .into_iter() + .map(|app_id| DesktopEntryData { + id: app_id.to_string(), + name: app_id.to_string(), + icon: fde::IconSource::default(), + ..Default::default() + }) + }) + .flatten(), + ) + } else { + applications + } +} + +#[cfg(not(windows))] +pub fn load_desktop_file(locales: &[String], path: PathBuf) -> Option { + fde::DesktopEntry::from_path(path, Some(locales)) + .ok() + .map(|de| DesktopEntryData::from_desktop_entry(locales, de)) +} + +#[cfg(not(windows))] +impl DesktopEntryData { + pub fn from_desktop_entry(locales: &[String], de: fde::DesktopEntry) -> DesktopEntryData { + let name = de + .name(locales) + .unwrap_or(Cow::Borrowed(&de.appid)) + .to_string(); + + // check if absolute path exists and otherwise treat it as a name + let icon = fde::IconSource::from_unknown(de.icon().unwrap_or(&de.appid)); + + DesktopEntryData { + id: de.appid.to_string(), + wm_class: de.startup_wm_class().map(ToString::to_string), + exec: de.exec().map(ToString::to_string), + name, + icon, + categories: de + .categories() + .unwrap_or_default() + .into_iter() + .map(std::string::ToString::to_string) + .collect(), + desktop_actions: de + .actions() + .map(|actions| { + actions + .into_iter() + .filter_map(|action| { + let name = de.action_entry_localized(action, "Name", locales); + let exec = de.action_entry(action, "Exec"); + if let (Some(name), Some(exec)) = (name, exec) { + Some(DesktopAction { + name: name.to_string(), + exec: exec.to_string(), + }) + } else { + None + } + }) + .collect::>() + }) + .unwrap_or_default(), + mime_types: de + .mime_type() + .map(|mime_types| { + mime_types + .into_iter() + .filter_map(|mime_type| mime_type.parse::().ok()) + .collect::>() + }) + .unwrap_or_default(), + prefers_dgpu: de.prefers_non_default_gpu(), + terminal: de.terminal(), + path: Some(de.path), + } + } +} + +#[cfg(not(windows))] +#[cold] +pub async fn spawn_desktop_exec( + exec: S, + env_vars: I, + app_id: Option<&str>, + terminal: bool, +) where + S: AsRef, + I: IntoIterator, + K: AsRef, + V: AsRef, +{ + let term_exec; + + let exec_str = if terminal { + let term = cosmic_settings_config::shortcuts::context() + .ok() + .and_then(|config| { + cosmic_settings_config::shortcuts::system_actions(&config) + .get(&cosmic_settings_config::shortcuts::action::System::Terminal) + .cloned() + }) + .unwrap_or_else(|| String::from("cosmic-term")); + + term_exec = format!("{term} -e {}", exec.as_ref()); + &term_exec + } else { + exec.as_ref() + }; + + let mut exec = shlex::Shlex::new(exec_str); + + let executable = match exec.next() { + Some(executable) if !executable.contains('=') => executable, + _ => return, + }; + + let mut cmd = std::process::Command::new(&executable); + + for arg in exec { + // TODO handle "%" args here if necessary? + if !arg.starts_with('%') { + cmd.arg(arg); + } + } + + cmd.envs(env_vars); + + // https://systemd.io/DESKTOP_ENVIRONMENTS + // + // Similar to what Gnome sets, for now. + if let Some(pid) = crate::process::spawn(cmd).await { + #[cfg(feature = "desktop-systemd-scope")] + if let Ok(session) = zbus::Connection::session().await { + if let Ok(systemd_manager) = SystemdMangerProxy::new(&session).await { + let _ = systemd_manager + .start_transient_unit( + &format!("app-cosmic-{}-{}.scope", app_id.unwrap_or(&executable), pid), + "fail", + &[ + ( + "Description".to_string(), + zbus::zvariant::Value::from("Application launched by COSMIC") + .try_to_owned() + .unwrap(), + ), + ( + "PIDs".to_string(), + zbus::zvariant::Value::from(vec![pid]) + .try_to_owned() + .unwrap(), + ), + ( + "CollectMode".to_string(), + zbus::zvariant::Value::from("inactive-or-failed") + .try_to_owned() + .unwrap(), + ), + ], + &[], + ) + .await; + } + } + } +} + +#[cfg(not(windows))] +#[cfg(feature = "desktop-systemd-scope")] +#[zbus::proxy( + interface = "org.freedesktop.systemd1.Manager", + default_service = "org.freedesktop.systemd1", + default_path = "/org/freedesktop/systemd1" +)] +trait SystemdManger { + async fn start_transient_unit( + &self, + name: &str, + mode: &str, + properties: &[(String, zbus::zvariant::OwnedValue)], + aux: &[(String, Vec<(String, zbus::zvariant::OwnedValue)>)], + ) -> zbus::Result; +} + +#[cfg(all(test, not(windows)))] +mod tests { + use super::*; + use std::{env, fs, path::Path, path::PathBuf}; + use tempfile::tempdir; + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &Path) -> Self { + let original = env::var(key).ok(); + // std::env::{set_var, remove_var} are unsafe on newer toolchains; + // we limit scope here to the test helper that toggles a single key. + unsafe { std::env::set_var(key, value) }; + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(ref original) = self.original { + unsafe { std::env::set_var(self.key, original) }; + } else { + unsafe { std::env::remove_var(self.key) }; + } + } + } + + fn load_entry(file_name: &str, contents: &str, locales: &[String]) -> fde::DesktopEntry { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join(file_name); + fs::write(&path, contents).expect("write desktop file"); + let entry = fde::DesktopEntry::from_path(path, Some(locales)).expect("load desktop file"); + // Ensure directory stays alive until after parsing + temp.close().expect("close tempdir"); + entry + } + + #[test] + fn candidate_generation_covers_common_variants() { + let ctx = DesktopLookupContext::new("com.example.App.desktop") + .with_identifier("com-example-App") + .with_title("Example App"); + let candidates = candidate_desktop_ids(&ctx); + + assert_eq!(candidates.first().unwrap(), "com.example.App.desktop"); + for test in [ + "com.example.App", + "com-example-App", + "com_example_App", + "Example App", + "Example", + "App", + ] { + assert!( + candidates + .iter() + .any(|c| c.to_ascii_lowercase() == test.to_ascii_lowercase()), + ); + } + } + + #[test] + fn startup_wm_class_matching_detects_flatpak_chrome_apps() { + let temp = tempdir().expect("tempdir"); + let apps_dir = temp.path().join("applications"); + fs::create_dir_all(&apps_dir).expect("create applications dir"); + + let desktop_contents = "\ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Proton Mail +Exec=chromium --app-id=jnpecgipniidlgicjocehkhajgdnjekh +Icon=chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default +StartupWMClass=crx_jnpecgipniidlgicjocehkhajgdnjekh +"; + let desktop_path = apps_dir.join( + "org.chromium.Chromium.flextop.chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default.desktop", + ); + fs::write(desktop_path, desktop_contents).expect("write desktop file"); + + let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path()); + + let locales = vec!["en_US.UTF-8".to_string()]; + let mut cache = DesktopEntryCache::new(locales.clone()); + cache.refresh(); + + let ctx = DesktopLookupContext::new("crx_jnpecgipniidlgicjocehkhajgdnjekh"); + let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default()); + + assert_eq!( + resolved.id(), + "org.chromium.Chromium.flextop.chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default" + ); + } + + #[test] + fn exec_basename_matching_handles_vmware() { + let temp = tempdir().expect("tempdir"); + let apps_dir = temp.path().join("applications"); + fs::create_dir_all(&apps_dir).expect("create applications dir"); + + let desktop_contents = "\ +[Desktop Entry]\n\ +Version=1.0\n\ +Type=Application\n\ +Name=VMware Workstation\n\ +Exec=/usr/bin/vmware %U\n\ +Icon=vmware-workstation\n\ +"; + let desktop_path = apps_dir.join("vmware-workstation.desktop"); + fs::write(desktop_path, desktop_contents).expect("write desktop file"); + + let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path()); + + let locales = vec!["en_US.UTF-8".to_string()]; + let mut cache = DesktopEntryCache::new(locales.clone()); + cache.refresh(); + + let ctx = DesktopLookupContext::new("vmware").with_title("Library — VMware Workstation"); + + let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default()); + + assert_eq!(resolved.id(), "vmware-workstation"); + } + + #[test] + fn proton_fallback_prefers_game_entries() { + let locales = vec!["en_US.UTF-8".to_string()]; + let entry = load_entry( + "proton.desktop", + "[Desktop Entry]\nType=Application\nName=Proton Game\nCategories=Game;Utility;\nExec=proton-game\n", + &locales, + ); + let cache = DesktopEntryCache::from_entries(locales.clone(), vec![entry]); + let ctx = DesktopLookupContext::new("steam_app_default").with_title("Proton Game"); + + let resolved = proton_or_wine_fallback(&cache, &ctx).expect("expected proton match"); + let name = resolved + .name(&locales) + .expect("name available") + .into_owned(); + + assert_eq!(name, "Proton Game"); + } + + #[test] + fn proton_fallback_skips_non_games() { + let locales = vec!["en_US.UTF-8".to_string()]; + let entry = load_entry( + "tool.desktop", + "[Desktop Entry]\nType=Application\nName=Proton Tool\nCategories=Utility;\nExec=proton-tool\n", + &locales, + ); + let cache = DesktopEntryCache::from_entries(locales, vec![entry]); + let ctx = DesktopLookupContext::new("steam_app_default").with_title("Proton Tool"); + + assert!(proton_or_wine_fallback(&cache, &ctx).is_none()); + } + + #[test] + fn wine_fallback_matches_executable_titles() { + let locales = vec!["en_US.UTF-8".to_string()]; + let entry = load_entry( + "wine.desktop", + "[Desktop Entry]\nType=Application\nName=Wine Game\nExec=wine-game\n", + &locales, + ); + let cache = DesktopEntryCache::from_entries(locales.clone(), vec![entry]); + let ctx = DesktopLookupContext::new("WINEGAME.EXE").with_title("Wine Game"); + + let resolved = proton_or_wine_fallback(&cache, &ctx).expect("expected wine match"); + let name = resolved + .name(&locales) + .expect("name available") + .into_owned(); + assert_eq!(name, "Wine Game"); + } + + #[test] + fn fallback_entry_uses_title_when_available() { + let ctx = DesktopLookupContext::new("unknown-app").with_title("Unknown App"); + let entry = fallback_entry(&ctx); + + assert_eq!(entry.id(), "unknown-app"); + assert_eq!( + entry.name(&["en_US".to_string()]), + Some(Cow::Owned("Unknown App".to_string())) + ); + } + + #[test] + fn desktop_entry_data_prefers_localized_name() { + let locales = vec!["fr".to_string(), "en_US".to_string()]; + let entry = load_entry( + "localized.desktop", + "[Desktop Entry]\nType=Application\nName=Default\nName[fr]=Localisé\nExec=localized\n", + &locales, + ); + let data = DesktopEntryData::from_desktop_entry(&locales, entry); + + assert_eq!(data.name, "Localisé"); + } + + #[test] + fn crx_id_extraction_variants() { + let id = "cadlkienfkclaiaibeoongdcgmdikeeg"; // 32 chars a..p + assert_eq!( + super::extract_crx_id(&format!("chrome-{}-Default", id)), + Some(id.to_string()) + ); + assert_eq!( + super::extract_crx_id(&format!("crx_{}", id)), + Some(id.to_string()) + ); + assert_eq!(super::extract_crx_id(id), Some(id.to_string())); + // Embedded + let embedded = format!("org.chromium.Chromium.flextop.chrome-{}-Default", id); + assert_eq!(super::extract_crx_id(&embedded), Some(id.to_string())); + } + + #[test] + fn crx_matcher_by_exec_and_wmclass() { + use std::fs; + let id = "cadlkienfkclaiaibeoongdcgmdikeeg"; + let temp = tempdir().expect("tempdir"); + let apps_dir = temp.path().join("applications"); + fs::create_dir_all(&apps_dir).expect("create applications dir"); + let desktop_contents = format!( + "[Desktop Entry]\nType=Application\nName=ChatGPT\nExec=chromium --app-id={} --profile-directory=Default\nStartupWMClass=crx_{}\nIcon=chrome-{}-Default\n", + id, id, id + ); + let desktop_path = apps_dir.join( + "org.chromium.Chromium.flextop.chrome-cadlkienfkclaiaibeoongdcgmdikeeg-Default.desktop", + ); + fs::write(&desktop_path, desktop_contents).expect("write desktop file"); + + let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path()); + let locales = vec!["en_US.UTF-8".to_string()]; + let mut cache = DesktopEntryCache::new(locales.clone()); + cache.refresh(); + + let short_id = format!("chrome-{}-Default", id); + let ctx = DesktopLookupContext::new(short_id); + let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default()); + assert!(resolved.icon().is_some()); + assert!(resolved.exec().is_some()); + let expected = format!("crx_{}", id); + assert_eq!(resolved.startup_wm_class(), Some(expected.as_str())); + } + + #[test] + fn crx_extraction_handles_utf8_prefixes() { + let id = "cadlkienfkclaiaibeoongdcgmdikeeg"; + let prefixed = format!("å{}", id); + assert_eq!(super::extract_crx_id(&prefixed), Some(id.to_string())); + } + + #[test] + fn crx_extraction_ignores_non_ascii_sequences() { + let id = "cadlkienfkclaiaibeoongdcgmdikeeg"; + let embedded = format!("{id}æøå"); + + assert_eq!(super::extract_crx_id(&embedded), Some(id.to_string())); + assert_eq!(super::extract_crx_id("æøå"), None); + } +} diff --git a/src/dialog/file_chooser/mod.rs b/src/dialog/file_chooser/mod.rs index ccb15c42..186f7625 100644 --- a/src/dialog/file_chooser/mod.rs +++ b/src/dialog/file_chooser/mod.rs @@ -2,219 +2,147 @@ // SPDX-License-Identifier: MPL-2.0 //! Dialogs for opening and save files. +//! +//! # Features +//! +//! - On Linux, the `xdg-portal` feature will use XDG Portal dialogs. +//! - Alternatively, `rfd` can be used for platform support beyond Linux. +//! +//! # Open a file +//! +//! ```no_run +//! cosmic::task::future(async { +//! use cosmic::dialog::file_chooser; +//! +//! let dialog = file_chooser::open::Dialog::new() +//! .title("Choose a file"); +//! +//! match dialog.open_file().await { +//! Ok(response) => println!("selected to open {:?}", response.url()), +//! +//! Err(file_chooser::Error::Cancelled) => (), +//! +//! Err(why) => eprintln!("error selecting file to open: {why:?}") +//! } +//! }); +//! ``` +//! +//! # Open multiple files +//! +//! ```no_run +//! cosmic::task::future(async { +//! use cosmic::dialog::file_chooser; +//! +//! let dialog = file_chooser::open::Dialog::new() +//! .title("Choose multiple files"); +//! +//! match dialog.open_files().await { +//! Ok(response) => println!("selected to open {:?}", response.urls()), +//! +//! Err(file_chooser::Error::Cancelled) => (), +//! +//! Err(why) => eprintln!("error selecting file(s) to open: {why:?}") +//! } +//! }); +//! ``` +//! +//! # Open a folder +//! +//! ```no_run +//! cosmic::task::future(async { +//! use cosmic::dialog::file_chooser; +//! +//! let dialog = file_chooser::open::Dialog::new() +//! .title("Choose a folder"); +//! +//! match dialog.open_folder().await { +//! Ok(response) => println!("selected to open {:?}", response.url()), +//! +//! Err(file_chooser::Error::Cancelled) => (), +//! +//! Err(why) => eprintln!("error selecting folder to open: {why:?}") +//! } +//! }); +//! ``` +//! +//! # Open multiple folders +//! +//! ```no_run +//! cosmic::task::future(async { +//! use cosmic::dialog::file_chooser; +//! +//! let dialog = file_chooser::open::Dialog::new() +//! .title("Choose a folder"); +//! +//! match dialog.open_folders().await { +//! Ok(response) => println!("selected to open {:?}", response.urls()), +//! +//! Err(file_chooser::Error::Cancelled) => (), +//! +//! Err(why) => eprintln!("error selecting folder(s) to open: {why:?}") +//! } +//! }); +//! ``` +/// Open file dialog. pub mod open; + +/// Save file dialog. pub mod save; -pub use ashpd::desktop::file_chooser::{Choice, FileFilter, SelectedFiles}; -use iced::futures::{channel, SinkExt, StreamExt}; -use iced::{Command, Subscription}; -use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(feature = "xdg-portal")] +pub use ashpd::desktop::file_chooser::{Choice, FileFilter}; + use thiserror::Error; -/// Prevents duplicate file chooser dialog requests. -static OPENED: AtomicBool = AtomicBool::new(false); - -/// Whether a file chooser dialog is currently active. -fn dialog_active() -> bool { - OPENED.load(Ordering::Relaxed) +/// A file filter, to limit the available file choices to certain extensions. +#[cfg(feature = "rfd")] +#[must_use] +pub struct FileFilter { + description: String, + extensions: Vec, } -/// Sets the existence of a file chooser dialog. -fn dialog_active_set(value: bool) { - OPENED.store(value, Ordering::SeqCst); -} - -/// Creates an [`open::Dialog`] if no other file chooser exists. -pub fn open_file() -> Option { - if dialog_active() { - None - } else { - Some(open::Dialog::new()) - } -} - -/// Creates a [`save::Dialog`] if no other file chooser exists. -pub fn save_file() -> Option { - if dialog_active() { - None - } else { - Some(save::Dialog::new()) - } -} - -/// Creates a subscription for file chooser events. -pub fn subscription(handle: H) -> Subscription -where - M: Send + 'static, - H: Fn(Message) -> M + Send + Sync + 'static, -{ - let type_id = std::any::TypeId::of::>(); - - iced::subscription::channel(type_id, 1, move |output| async move { - let mut state = Handler { - active: None, - handle, - output, - }; - - loop { - let (sender, mut receiver) = channel::mpsc::channel(1); - - state.emit(Message::Init(Sender(sender))).await; - - while let Some(request) = receiver.next().await { - match request { - Request::Close => state.close().await, - - Request::Open(dialog) => { - state.open(dialog).await; - dialog_active_set(false); - } - - Request::Save(dialog) => { - state.save(dialog).await; - dialog_active_set(false); - } - - Request::Response => state.response().await, - } - } +#[cfg(feature = "rfd")] +impl FileFilter { + pub fn new(description: impl Into) -> Self { + Self { + description: description.into(), + extensions: Vec::new(), } - }) + } + + pub fn extension(mut self, extension: impl Into) -> Self { + self.extensions.push(extension.into()); + self + } } /// Errors that my occur when interacting with the file chooser subscription #[derive(Debug, Error)] pub enum Error { + #[error("dialog request cancelled")] + Cancelled, #[error("dialog close failed")] - Close(#[source] ashpd::Error), - #[error("dialog open failed")] - Open(#[source] ashpd::Error), + Close(#[source] DialogError), + #[error("open dialog failed")] + Open(#[source] DialogError), #[error("dialog response failed")] - Response(#[source] ashpd::Error), + Response(#[source] DialogError), + #[error("save dialog failed")] + Save(#[source] DialogError), + #[error("could not set directory")] + SetDirectory(#[source] DialogError), + #[error("could not set absolute path for file name")] + SetAbsolutePath(#[source] DialogError), + #[error("path from dialog was not absolute")] + UrlAbsolute, } -/// Requests for the file chooser subscription -enum Request { - Close, - Open(open::Dialog), - Save(save::Dialog), - Response, -} +#[cfg(feature = "xdg-portal")] +pub type DialogError = ashpd::Error; -/// Messages from the file chooser subscription. -pub enum Message { - Closed, - Err(Error), - Init(Sender), - Opened, - Selected(SelectedFiles), -} - -/// Sends requests to the file chooser subscription. -#[derive(Clone, Debug)] -pub struct Sender(channel::mpsc::Sender); - -impl Sender { - /// Creates a [`Command`] that closes a file chooser dialog. - pub fn close(&mut self) -> Command<()> { - let mut sender = self.0.clone(); - - crate::command::future(async move { - let _res = sender.send(Request::Close).await; - () - }) - } - - /// Creates a [`Command`] that opens the file chooser. - pub fn open(&mut self, dialog: open::Dialog) -> Command<()> { - dialog_active_set(true); - let mut sender = self.0.clone(); - - crate::command::future(async move { - let _res = sender.send(Request::Open(dialog)).await; - () - }) - } - - /// Creates a [`Command`] that requests the response from a file chooser dialog. - pub fn response(&mut self) -> Command<()> { - let mut sender = self.0.clone(); - - crate::command::future(async move { - let _res = sender.send(Request::Response).await; - () - }) - } - - /// Creates a [`Command`] that opens a new save file dialog. - pub fn save(&mut self, dialog: save::Dialog) -> Command<()> { - dialog_active_set(true); - let mut sender = self.0.clone(); - - crate::command::future(async move { - let _res = sender.send(Request::Save(dialog)).await; - () - }) - } -} - -struct Handler M> { - active: Option>, - handle: Handle, - output: channel::mpsc::Sender, -} - -impl M> Handler { - /// Emits close request if there is an active dialog request. - async fn close(&mut self) { - if let Some(request) = self.active.take() { - if let Err(why) = request.close().await { - self.emit(Message::Err(Error::Close(why))).await; - } - } - } - - async fn emit(&mut self, response: Message) { - let _res = self.output.send((self.handle)(response)).await; - } - - /// Creates a new dialog, and closes any prior active dialogs. - async fn open(&mut self, dialog: open::Dialog) { - let response = match open::create(dialog).await { - Ok(request) => { - self.active = Some(request); - Message::Opened - } - Err(why) => Message::Err(Error::Open(why)), - }; - - self.emit(response).await; - } - - /// Collects selected files from the active dialog. - async fn response(&mut self) { - if let Some(request) = self.active.as_ref() { - let response = match request.response() { - Ok(selected) => Message::Selected(selected), - Err(why) => Message::Err(Error::Response(why)), - }; - - self.emit(response).await; - } - } - - /// Creates a new dialog, and closes any prior active dialogs. - async fn save(&mut self, dialog: save::Dialog) { - let response = match save::create(dialog).await { - Ok(request) => { - self.active = Some(request); - Message::Opened - } - Err(why) => Message::Err(Error::Open(why)), - }; - - self.emit(response).await; - } -} +#[cfg(feature = "rfd")] +#[derive(Debug, Error)] +#[error("no file selected")] +pub struct DialogError {} diff --git a/src/dialog/file_chooser/open.rs b/src/dialog/file_chooser/open.rs index 20d4176b..f24afda9 100644 --- a/src/dialog/file_chooser/open.rs +++ b/src/dialog/file_chooser/open.rs @@ -6,85 +6,318 @@ //! Check out the [open-dialog](https://github.com/pop-os/libcosmic/tree/master/examples/open-dialog) //! example in our repository. -use derive_setters::Setters; -use iced::Command; +#[cfg(feature = "xdg-portal")] +pub use portal::{FileResponse, MultiFileResponse, file, files, folder, folders}; -/// A builder for an open file dialog, passed as a request by a [`Sender`] -#[derive(Setters)] +#[cfg(feature = "rfd")] +pub use rust_fd::{FileResponse, MultiFileResponse, file, files, folder, folders}; + +use super::Error; +use std::path::PathBuf; + +/// A builder for an open file dialog +#[derive(derive_setters::Setters)] #[must_use] pub struct Dialog { /// The label for the dialog's window title. + #[setters(into)] title: String, /// The label for the accept button. Mnemonic underlines are allowed. - #[setters(strip_option)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] accept_label: Option, - /// Whether to select for folders instead of files. Default is to select files. - include_directories: bool, + /// Sets the starting directory of the dialog. + #[setters(into, strip_option)] + #[allow(dead_code)] // TODO: ashpd does not expose this yet + directory: Option, + + /// Set starting file name of the dialog. + #[setters(into, strip_option)] + #[allow(dead_code)] // TODO: ashpd does not expose this yet + file_name: Option, /// Modal dialogs require user input before continuing the program. + #[cfg(feature = "xdg-portal")] + #[setters(skip)] modal: bool, - /// Whether to allow selection of multiple files. Default is no. - multiple_files: bool, - /// Adds a list of choices. + #[cfg(feature = "xdg-portal")] + #[setters(skip)] choices: Vec, /// Specifies the default file filter. - #[setters(into)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] current_filter: Option, /// A collection of file filters. - filters: Vec, + #[setters(skip)] + pub(self) filters: Vec, } impl Dialog { - pub(super) const fn new() -> Self { + pub const fn new() -> Self { Self { title: String::new(), + #[cfg(feature = "xdg-portal")] accept_label: None, - include_directories: false, + directory: None, + file_name: None, + #[cfg(feature = "xdg-portal")] modal: true, - multiple_files: false, + #[cfg(feature = "xdg-portal")] current_filter: None, + #[cfg(feature = "xdg-portal")] choices: Vec::new(), filters: Vec::new(), } } - /// Creates a [`Command`] which opens the dialog. - pub fn create(self, sender: &mut super::Sender) -> Command<()> { - sender.open(self) + /// The label for the accept button. Mnemonic underlines are allowed. + #[cfg(feature = "xdg-portal")] + pub fn accept_label(mut self, label: impl Into) -> Self { + self.accept_label = Some(label.into()); + self } /// Adds a choice. + #[cfg(feature = "xdg-portal")] pub fn choice(mut self, choice: impl Into) -> Self { self.choices.push(choice.into()); self } + /// Specifies the default file filter. + #[cfg(feature = "xdg-portal")] + pub fn current_filter(mut self, filter: impl Into) -> Self { + self.current_filter = Some(filter.into()); + self + } + /// Adds a files filter. pub fn filter(mut self, filter: impl Into) -> Self { self.filters.push(filter.into()); self } + + /// Modal dialogs require user input before continuing the program. + #[cfg(feature = "xdg-portal")] + pub fn modal(mut self, modal: bool) -> Self { + self.modal = modal; + self + } + + /// Create an open file dialog. + pub async fn open_file(self) -> Result { + file(self).await + } + + /// Create an open file dialog with multiple file select. + pub async fn open_files(self) -> Result { + files(self).await + } + + /// Create an open folder dialog. + pub async fn open_folder(self) -> Result { + folder(self).await + } + + /// Create an open folder dialog with multi file select. + pub async fn open_folders(self) -> Result { + folders(self).await + } } -/// Creates a new file dialog, and begins to await its responses. -pub(super) async fn create( - dialog: Dialog, -) -> ashpd::Result> { - ashpd::desktop::file_chooser::OpenFileRequest::default() - .title(Some(dialog.title.as_str())) - .accept_label(dialog.accept_label.as_deref()) - .directory(dialog.include_directories) - .modal(dialog.modal) - .multiple(dialog.multiple_files) - .choices(dialog.choices) - .filters(dialog.filters) - .current_filter(dialog.current_filter) - .send() - .await +#[cfg(feature = "xdg-portal")] +mod portal { + use super::Dialog; + use crate::dialog::file_chooser::Error; + use ashpd::desktop::file_chooser::SelectedFiles; + use url::Url; + + fn error_or_cancel(error: ashpd::Error) -> Error { + if let ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled) = error { + Error::Cancelled + } else { + Error::Open(error) + } + } + + /// Creates a new file dialog, and begins to await its responses. + #[cfg(feature = "xdg-portal")] + pub async fn create( + dialog: super::Dialog, + folders: bool, + multiple: bool, + ) -> Result, Error> { + // TODO: Set window identifier + ashpd::desktop::file_chooser::OpenFileRequest::default() + .title(Some(dialog.title.as_str())) + .accept_label(dialog.accept_label.as_deref()) + .directory(folders) + .modal(dialog.modal) + .multiple(multiple) + .choices(dialog.choices) + .filters(dialog.filters) + .current_filter(dialog.current_filter) + .send() + .await + .map_err(error_or_cancel) + } + + fn file_response( + request: ashpd::desktop::Request, + ) -> Result { + request + .response() + .map(FileResponse) + .map_err(error_or_cancel) + } + + fn multi_file_response( + request: ashpd::desktop::Request, + ) -> Result { + request + .response() + .map(MultiFileResponse) + .map_err(error_or_cancel) + } + + pub async fn file(dialog: Dialog) -> Result { + file_response(create(dialog, false, false).await?) + } + + pub async fn files(dialog: Dialog) -> Result { + multi_file_response(create(dialog, false, true).await?) + } + + pub async fn folder(dialog: Dialog) -> Result { + file_response(create(dialog, true, false).await?) + } + + pub async fn folders(dialog: Dialog) -> Result { + multi_file_response(create(dialog, true, true).await?) + } + + /// A dialog response containing the selected file or folder. + pub struct FileResponse(pub SelectedFiles); + + impl FileResponse { + pub fn choices(&self) -> &[(String, String)] { + self.0.choices() + } + + pub fn url(&self) -> &Url { + self.0.uris().first().expect("no files selected") + } + } + + /// A dialog response containing the selected file(s) or folder(s). + pub struct MultiFileResponse(pub SelectedFiles); + + impl MultiFileResponse { + pub fn choices(&self) -> &[(String, String)] { + self.0.choices() + } + + pub fn urls(&self) -> &[Url] { + self.0.uris() + } + } +} + +#[cfg(feature = "rfd")] +mod rust_fd { + use super::Dialog; + use crate::dialog::file_chooser::Error; + use url::Url; + + pub fn create(dialog: Dialog) -> rfd::AsyncFileDialog { + let mut builder = rfd::AsyncFileDialog::new().set_title(dialog.title); + + if let Some(directory) = dialog.directory { + builder = builder.set_directory(directory); + } + + if let Some(file_name) = dialog.file_name { + builder = builder.set_file_name(file_name); + } + + for filter in dialog.filters { + builder = builder.add_filter(filter.description, &filter.extensions); + } + + builder + } + + fn file_response(request: Option) -> Result { + if let Some(handle) = request { + let url = Url::from_file_path(handle.path()).map_err(|_| Error::UrlAbsolute)?; + + return Ok(FileResponse(url)); + } + + Err(Error::Cancelled) + } + + fn multi_file_response( + request: Option>, + ) -> Result { + if let Some(handles) = request { + let mut urls = Vec::with_capacity(handles.len()); + + for handle in &handles { + urls.push(Url::from_file_path(handle.path()).map_err(|()| Error::UrlAbsolute)?); + } + + return Ok(MultiFileResponse(urls)); + } + + Err(Error::Cancelled) + } + + pub async fn file(dialog: Dialog) -> Result { + file_response(create(dialog).pick_file().await) + } + + pub async fn files(dialog: Dialog) -> Result { + multi_file_response(create(dialog).pick_files().await) + } + + pub async fn folder(dialog: Dialog) -> Result { + file_response(create(dialog).pick_folder().await) + } + + pub async fn folders(dialog: Dialog) -> Result { + multi_file_response(create(dialog).pick_folders().await) + } + + /// A dialog response containing the selected file or folder. + pub struct FileResponse(Url); + + impl FileResponse { + pub fn choices(&self) -> &[(String, String)] { + &[] + } + + pub fn url(&self) -> &Url { + &self.0 + } + } + + /// A dialog response containing the selected file(s) or folder(s). + pub struct MultiFileResponse(Vec); + + impl MultiFileResponse { + pub fn choices(&self) -> &[(String, String)] { + &[] + } + + pub fn urls(&self) -> &[Url] { + &self.0 + } + } } diff --git a/src/dialog/file_chooser/save.rs b/src/dialog/file_chooser/save.rs index 0729b0f7..d7a2a34e 100644 --- a/src/dialog/file_chooser/save.rs +++ b/src/dialog/file_chooser/save.rs @@ -6,94 +6,207 @@ //! Check out the [open-dialog](https://github.com/pop-os/libcosmic/tree/master/examples/open-dialog) //! example in our repository. -use derive_setters::Setters; -use iced::Command; -use std::path::{Path, PathBuf}; +#[cfg(feature = "xdg-portal")] +pub use portal::{Response, file}; -/// A builder for an save file dialog, passed as a request by a [`Sender`] -#[derive(Setters)] +#[cfg(feature = "rfd")] +pub use rust_fd::{Response, file}; + +use super::Error; +use std::path::PathBuf; + +/// A builder for an save file dialog. +#[derive(derive_setters::Setters)] #[must_use] pub struct Dialog { /// The label for the dialog's window title. title: String, /// The label for the accept button. Mnemonic underlines are allowed. - #[setters(strip_option)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] accept_label: Option, /// Modal dialogs require user input before continuing the program. + #[cfg(feature = "xdg-portal")] + #[setters(skip)] modal: bool, - /// Sets the current file name. + /// Set starting file name of the dialog. #[setters(strip_option)] - current_name: Option, + file_name: Option, - /// Sets the current folder. + /// Sets the starting directory of the dialog. #[setters(strip_option)] - current_folder: Option, + directory: Option, /// Sets the absolute path of the file - #[setters(strip_option)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] current_file: Option, /// Adds a list of choices. + #[cfg(feature = "xdg-portal")] + #[setters(skip)] choices: Vec, /// Specifies the default file filter. - #[setters(into)] + #[cfg(feature = "xdg-portal")] + #[setters(skip)] current_filter: Option, /// A collection of file filters. + #[setters(skip)] filters: Vec, } impl Dialog { - pub(super) const fn new() -> Self { + pub const fn new() -> Self { Self { title: String::new(), + #[cfg(feature = "xdg-portal")] accept_label: None, + #[cfg(feature = "xdg-portal")] modal: true, - current_name: None, - current_folder: None, + file_name: None, + directory: None, + #[cfg(feature = "xdg-portal")] current_file: None, + #[cfg(feature = "xdg-portal")] current_filter: None, + #[cfg(feature = "xdg-portal")] choices: Vec::new(), filters: Vec::new(), } } - /// Creates a [`Command`] which opens the dialog. - pub fn create(self, sender: &mut super::Sender) -> Command<()> { - sender.save(self) + /// The label for the accept button. Mnemonic underlines are allowed. + #[cfg(feature = "xdg-portal")] + pub fn accept_label(mut self, label: impl Into) -> Self { + self.accept_label = Some(label.into()); + self } /// Adds a choice. + #[cfg(feature = "xdg-portal")] pub fn choice(mut self, choice: impl Into) -> Self { self.choices.push(choice.into()); self } + /// Set the current file filter. + #[cfg(feature = "xdg-portal")] + pub fn current_filter(mut self, filter: impl Into) -> Self { + self.current_filter = Some(filter.into()); + self + } + /// Adds a files filter. pub fn filter(mut self, filter: impl Into) -> Self { self.filters.push(filter.into()); self } + + /// Modal dialogs require user input before continuing the program. + #[cfg(feature = "xdg-portal")] + pub fn modal(mut self, modal: bool) -> Self { + self.modal = modal; + self + } + + /// Create a save file dialog request. + pub async fn save_file(self) -> Result { + file(self).await + } } -/// Creates a new file dialog, and begins to await its responses. -pub(super) async fn create( - dialog: Dialog, -) -> ashpd::Result> { - ashpd::desktop::file_chooser::SaveFileRequest::default() - .title(Some(dialog.title.as_str())) - .accept_label(dialog.accept_label.as_deref()) - .modal(dialog.modal) - .choices(dialog.choices) - .filters(dialog.filters) - .current_filter(dialog.current_filter) - .current_name(dialog.current_name.as_deref()) - .current_folder::<&Path>(dialog.current_folder.as_deref())? - .current_file::<&Path>(dialog.current_file.as_deref())? - .send() - .await +impl Default for Dialog { + fn default() -> Self { + Self::new() + } +} + +#[cfg(feature = "xdg-portal")] +mod portal { + use super::Dialog; + use crate::dialog::file_chooser::Error; + use ashpd::desktop::file_chooser::SelectedFiles; + use std::path::Path; + use url::Url; + + /// Create a save file dialog request. + pub async fn file(dialog: Dialog) -> Result { + ashpd::desktop::file_chooser::SaveFileRequest::default() + .title(Some(dialog.title.as_str())) + .accept_label(dialog.accept_label.as_deref()) + .modal(dialog.modal) + .choices(dialog.choices) + .filters(dialog.filters) + .current_filter(dialog.current_filter) + .current_name(dialog.file_name.as_deref()) + .current_folder::<&Path>(dialog.directory.as_deref()) + .map_err(Error::SetDirectory)? + .current_file::<&Path>(dialog.current_file.as_deref()) + .map_err(Error::SetAbsolutePath)? + .send() + .await + .map_err(Error::Save)? + .response() + .map_err(Error::Save) + .map(Response) + } + + /// A dialog response containing the selected file or folder. + pub struct Response(pub SelectedFiles); + + impl Response { + pub fn choices(&self) -> &[(String, String)] { + self.0.choices() + } + + pub fn url(&self) -> Option<&Url> { + self.0.uris().first() + } + } +} + +#[cfg(feature = "rfd")] +mod rust_fd { + use super::Dialog; + use crate::dialog::file_chooser::Error; + use url::Url; + + /// Create a save file dialog request. + pub async fn file(dialog: Dialog) -> Result { + let mut request = rfd::AsyncFileDialog::new().set_title(dialog.title); + + if let Some(directory) = dialog.directory { + request = request.set_directory(directory); + } + + if let Some(file_name) = dialog.file_name { + request = request.set_file_name(file_name); + } + + for filter in dialog.filters { + request = request.add_filter(filter.description, &filter.extensions); + } + + if let Some(handle) = request.save_file().await { + let url = Url::from_file_path(handle.path()).map_err(|_| Error::UrlAbsolute)?; + + return Ok(Response(Some(url))); + } + + Ok(Response(None)) + } + + /// A dialog response containing the selected file or folder. + pub struct Response(Option); + + impl Response { + pub fn url(&self) -> Option<&Url> { + self.0.as_ref() + } + } } diff --git a/src/dialog/mod.rs b/src/dialog/mod.rs index dc753096..66b3cec7 100644 --- a/src/dialog/mod.rs +++ b/src/dialog/mod.rs @@ -3,6 +3,7 @@ //! Create dialogs for retrieving user input. -pub use ashpd::WindowIdentifier; +#[cfg(feature = "xdg-portal")] +pub use ashpd; pub mod file_chooser; diff --git a/src/executor/multi.rs b/src/executor/multi.rs index 50aa111e..5536db54 100644 --- a/src/executor/multi.rs +++ b/src/executor/multi.rs @@ -26,4 +26,8 @@ impl iced::Executor for Executor { let _guard = self.0.enter(); f() } + + fn block_on(&self, future: impl Future) -> T { + self.0.block_on(future) + } } diff --git a/src/executor/single.rs b/src/executor/single.rs index aaa4f9f5..7c42ae84 100644 --- a/src/executor/single.rs +++ b/src/executor/single.rs @@ -30,4 +30,8 @@ impl iced::Executor for Executor { let _guard = self.0.enter(); f() } + + fn block_on(&self, future: impl Future) -> T { + self.0.block_on(future) + } } diff --git a/src/ext.rs b/src/ext.rs index f182371f..8eb749e5 100644 --- a/src/ext.rs +++ b/src/ext.rs @@ -9,7 +9,7 @@ pub trait ElementExt { fn debug(self, debug: bool) -> Self; } -impl<'a, Message: 'static> ElementExt for crate::Element<'a, Message> { +impl ElementExt for crate::Element<'_, Message> { fn debug(self, debug: bool) -> Self { if debug { self.explain(Color::WHITE) @@ -19,71 +19,6 @@ impl<'a, Message: 'static> ElementExt for crate::Element<'a, Message> { } } -/// Additional methods for the [`Column`] and [`Row`] widgets. -pub trait CollectionWidget<'a, Message: 'a>: Widget -where - Self: Sized, -{ - /// Moves all the elements of `other` into `self`, leaving `other` empty. - #[must_use] - fn append(self, other: &mut Vec) -> Self - where - E: Into>; - - /// Appends all elements in an iterator to the widget. - #[must_use] - fn extend(mut self, iterator: impl Iterator) -> Self - where - E: Into>, - { - for item in iterator { - self = self.push(item.into()); - } - - self - } - - /// Pushes an element into the widget. - #[must_use] - fn push(self, element: impl Into>) -> Self; - - /// Conditionally pushes an element to the widget. - #[must_use] - fn push_maybe(self, element: Option>>) -> Self { - if let Some(element) = element { - self.push(element.into()) - } else { - self - } - } -} - -impl<'a, Message: 'a> CollectionWidget<'a, Message> for crate::widget::Column<'a, Message> { - fn append(self, other: &mut Vec) -> Self - where - E: Into>, - { - self.extend(other.drain(..)) - } - - fn push(self, element: impl Into>) -> Self { - self.push(element) - } -} - -impl<'a, Message: 'a> CollectionWidget<'a, Message> for crate::widget::Row<'a, Message> { - fn append(self, other: &mut Vec) -> Self - where - E: Into>, - { - self.extend(other.drain(..)) - } - - fn push(self, element: impl Into>) -> Self { - self.push(element) - } -} - pub trait ColorExt { /// Combines color with background to create appearance of transparency. #[must_use] diff --git a/src/font.rs b/src/font.rs index 8f1be266..e0eb4745 100644 --- a/src/font.rs +++ b/src/font.rs @@ -4,65 +4,38 @@ //! Select preferred fonts. pub use iced::Font; -use iced::{ - font::{load, Error}, - Command, -}; -use iced_core::font::Family; +use iced_core::font::Weight; -pub const DEFAULT: Font = FONT; - -pub const FONT: Font = Font { - family: Family::Name("Fira Sans"), - weight: iced_core::font::Weight::Normal, - stretch: iced_core::font::Stretch::Normal, - monospaced: false, -}; - -pub const FONT_DATA: &[u8] = include_bytes!("../res/Fira/FiraSans-Regular.otf"); - -pub const FONT_LIGHT: Font = Font { - family: Family::Name("Fira Sans"), - weight: iced_core::font::Weight::Light, - stretch: iced_core::font::Stretch::Normal, - monospaced: false, -}; - -pub const FONT_LIGHT_DATA: &[u8] = include_bytes!("../res/Fira/FiraSans-Light.otf"); - -pub const FONT_SEMIBOLD: Font = Font { - family: Family::Name("Fira Sans"), - weight: iced_core::font::Weight::Semibold, - stretch: iced_core::font::Stretch::Normal, - monospaced: false, -}; - -pub const FONT_SEMIBOLD_DATA: &[u8] = include_bytes!("../res/Fira/FiraSans-SemiBold.otf"); - -pub const FONT_BOLD: Font = Font { - family: Family::Name("Fira Sans"), - weight: iced_core::font::Weight::Bold, - stretch: iced_core::font::Stretch::Normal, - monospaced: false, -}; - -pub const FONT_BOLD_DATA: &[u8] = include_bytes!("../res/Fira/FiraSans-Bold.otf"); - -pub const FONT_MONO_REGULAR: Font = Font { - family: Family::Name("Fira Mono"), - weight: iced_core::font::Weight::Normal, - stretch: iced_core::font::Stretch::Normal, - monospaced: true, -}; - -pub const FONT_MONO_REGULAR_DATA: &[u8] = include_bytes!("../res/Fira/FiraMono-Regular.otf"); - -pub fn load_fonts() -> Command> { - Command::batch(vec![ - load(FONT_DATA), - load(FONT_LIGHT_DATA), - load(FONT_SEMIBOLD_DATA), - load(FONT_BOLD_DATA), - load(FONT_MONO_REGULAR_DATA), - ]) +#[inline] +pub fn default() -> Font { + Font::from(crate::config::interface_font()) +} + +#[inline] +pub fn light() -> Font { + Font { + weight: Weight::Light, + ..default() + } +} + +#[inline] +pub fn semibold() -> Font { + Font { + weight: Weight::Semibold, + ..default() + } +} + +#[inline] +pub fn bold() -> Font { + Font { + weight: Weight::Bold, + ..default() + } +} + +#[inline] +pub fn mono() -> Font { + Font::from(crate::config::monospace_font()) } diff --git a/src/icon_theme.rs b/src/icon_theme.rs index 6d8dfc89..69fe5841 100644 --- a/src/icon_theme.rs +++ b/src/icon_theme.rs @@ -4,20 +4,23 @@ //! Select the preferred icon theme. use std::borrow::Cow; -use std::cell::RefCell; +use std::sync::Mutex; -thread_local! { - /// The fallback icon theme to search if no icon theme was specified. - pub(crate) static DEFAULT: RefCell> = RefCell::new("Cosmic".into()); -} +pub const COSMIC: &str = "Cosmic"; + +pub(crate) static DEFAULT: Mutex> = Mutex::new(Cow::Borrowed(COSMIC)); /// The fallback icon theme to search if no icon theme was specified. #[must_use] +#[allow(clippy::missing_panics_doc)] +#[inline] pub fn default() -> String { - DEFAULT.with(|theme| theme.borrow().to_string()) + DEFAULT.lock().unwrap().to_string() } /// Set the fallback icon theme to search when loading system icons. +#[allow(clippy::missing_panics_doc)] +#[cold] pub fn set_default(name: impl Into>) { - DEFAULT.with(|theme| *theme.borrow_mut() = name.into()); + *DEFAULT.lock().unwrap() = name.into(); } diff --git a/src/keyboard_nav.rs b/src/keyboard_nav.rs index 40294a07..961a423b 100644 --- a/src/keyboard_nav.rs +++ b/src/keyboard_nav.rs @@ -3,66 +3,56 @@ //! Subscribe to common application keyboard shortcuts. -use iced::{ - event, - keyboard::{self, KeyCode}, - mouse, subscription, Command, Event, Subscription, -}; -use iced_core::{ - widget::{operation, Id, Operation}, - Rectangle, -}; +use iced::{Event, Subscription, event, keyboard}; +use iced_core::keyboard::key::Named; +use iced_futures::event::listen_raw; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Message { +pub enum Action { Escape, FocusNext, FocusPrevious, Fullscreen, - Unfocus, Search, } -pub fn subscription() -> Subscription { - subscription::events_with(|event, status| { +#[cold] +pub fn subscription() -> Subscription { + listen_raw(|event, status, _| { if event::Status::Ignored != status { return None; } match event { Event::Keyboard(keyboard::Event::KeyPressed { - key_code, + key: keyboard::Key::Named(key), modifiers, - }) => match key_code { - KeyCode::Tab => { + .. + }) => match key { + Named::Tab if !modifiers.control() => { return Some(if modifiers.shift() { - Message::FocusPrevious + Action::FocusPrevious } else { - Message::FocusNext + Action::FocusNext }); } - KeyCode::Escape => { - return Some(Message::Escape); + Named::Escape => { + return Some(Action::Escape); } - KeyCode::F11 => { - return Some(Message::Fullscreen); - } - - KeyCode::F => { - return if modifiers.control() { - Some(Message::Search) - } else { - None - }; + Named::F11 => { + return Some(Action::Fullscreen); } _ => (), }, - - Event::Mouse(mouse::Event::ButtonPressed { .. }) => { - return Some(Message::Unfocus); + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(c), + modifiers, + .. + }) if c == "f" && modifiers.control() => { + return Some(Action::Search); } _ => (), @@ -71,32 +61,3 @@ pub fn subscription() -> Subscription { None }) } - -/// Unfocuses any actively-focused widget. -pub fn unfocus() -> Command { - Command::::widget(unfocus_operation()) -} - -#[must_use] -fn unfocus_operation() -> impl Operation { - struct Unfocus {} - - impl Operation for Unfocus { - fn focusable(&mut self, state: &mut dyn operation::Focusable, _id: Option<&Id>) { - if state.is_focused() { - state.unfocus(); - } - } - - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); - } - } - - Unfocus {} -} diff --git a/src/lib.rs b/src/lib.rs index 6a65e18d..02623799 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,34 +2,58 @@ // SPDX-License-Identifier: MPL-2.0 #![allow(clippy::module_name_repetitions)] - -#[cfg(all(feature = "wayland", feature = "winit"))] -compile_error!("cannot use `wayland` feature with `winit"); +#![cfg_attr(target_os = "redox", feature(lazy_cell))] +#![cfg_attr(docsrs, feature(doc_auto_cfg))] /// Recommended default imports. pub mod prelude { - pub use crate::ext::*; - #[cfg(any(feature = "winit", feature = "wayland"))] + #[cfg(feature = "winit")] pub use crate::ApplicationExt; - pub use crate::{Also, Apply, Element, Renderer, Theme}; + pub use crate::ext::*; + pub use crate::{Also, Apply, Element, Renderer, Task, Theme}; } pub use apply::{Also, Apply}; -#[cfg(any(feature = "winit", feature = "wayland"))] +/// Actions are managed internally by the cosmic runtime. +pub mod action; +pub use action::Action; + +pub mod anim; + +#[cfg(feature = "winit")] pub mod app; -#[cfg(any(feature = "winit", feature = "wayland"))] +#[cfg(feature = "winit")] +#[doc(inline)] pub use app::{Application, ApplicationExt}; #[cfg(feature = "applet")] pub mod applet; -pub use iced::Command; pub mod command; + +/// State which is managed by the cosmic runtime. +pub mod core; +#[doc(inline)] +pub use core::Core; + +pub mod config; + +#[doc(inline)] pub use cosmic_config; + +#[doc(inline)] pub use cosmic_theme; -#[cfg(feature = "xdg-portal")] +#[cfg(feature = "single-instance")] +pub mod dbus_activation; +#[cfg(feature = "single-instance")] +pub use dbus_activation::DbusActivation; + +#[cfg(feature = "desktop")] +pub mod desktop; + +#[cfg(any(feature = "xdg-portal", feature = "rfd"))] pub mod dialog; pub mod executor; @@ -40,31 +64,38 @@ mod ext; pub mod font; +#[doc(inline)] pub use iced; -pub use iced_core; -pub use iced_futures; -pub use iced_renderer; -pub use iced_runtime; -#[cfg(feature = "wayland")] -pub use iced_sctk; -pub use iced_style; -pub use iced_widget; -#[cfg(feature = "winit")] -pub use iced_winit; pub mod icon_theme; pub mod keyboard_nav; -#[cfg(feature = "process")] +mod localize; + +#[cfg(all(target_env = "gnu", not(target_os = "windows")))] +pub(crate) mod malloc; + +#[cfg(all(feature = "process", not(windows)))] pub mod process; -#[cfg(feature = "wayland")] +#[doc(inline)] +#[cfg(all(feature = "wayland", target_os = "linux"))] pub use cctk; +pub mod surface; + +pub use iced::Task; +pub mod task; + pub mod theme; -pub use theme::{style, Theme}; + +pub mod scroll; + +#[doc(inline)] +pub use theme::{Theme, style}; pub mod widget; - -pub type Renderer = iced::Renderer; -pub type Element<'a, Message> = iced::Element<'a, Message, Renderer>; +type Plain = iced_core::text::paragraph::Plain<::Paragraph>; +type Paragraph = ::Paragraph; +pub type Renderer = iced::Renderer; +pub type Element<'a, Message> = iced::Element<'a, Message, crate::Theme, Renderer>; diff --git a/src/localize.rs b/src/localize.rs new file mode 100644 index 00000000..95a31655 --- /dev/null +++ b/src/localize.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use i18n_embed::{ + DefaultLocalizer, LanguageLoader, Localizer, + fluent::{FluentLanguageLoader, fluent_language_loader}, +}; +use rust_embed::RustEmbed; +use std::sync::{LazyLock, OnceLock}; + +#[derive(RustEmbed)] +#[folder = "i18n/"] +struct Localizations; + +pub static LANGUAGE_LOADER: LazyLock = LazyLock::new(|| { + let loader: FluentLanguageLoader = fluent_language_loader!(); + + loader + .load_fallback_language(&Localizations) + .expect("Error while loading fallback language"); + + loader +}); + +static LOCALIZATION_INITIALIZED: OnceLock<()> = OnceLock::new(); + +#[macro_export] +macro_rules! fl { + ($message_id:literal) => {{ + $crate::localize::localize(); + i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id) + }}; + ($message_id:literal, $($args:expr),*) => {{ + $crate::localize::localize(); + i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id, $($args), *) + }}; +} + +// Get the `Localizer` to be used for localizing this library. +pub fn localizer() -> Box { + Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations)) +} + +pub fn localize() { + LOCALIZATION_INITIALIZED.get_or_init(|| { + let localizer = localizer(); + let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages(); + if let Err(error) = localizer.select(&requested_languages) { + eprintln!("Error while loading language for libcosmic {}", error); + } + }); +} diff --git a/src/malloc.rs b/src/malloc.rs new file mode 100644 index 00000000..b99a66f4 --- /dev/null +++ b/src/malloc.rs @@ -0,0 +1,27 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use std::os::raw::c_int; + +const M_MMAP_THRESHOLD: c_int = -3; + +unsafe extern "C" { + fn malloc_trim(pad: usize); + + fn mallopt(param: c_int, value: c_int) -> c_int; +} + +#[inline] +pub fn trim(pad: usize) { + unsafe { + malloc_trim(pad); + } +} + +/// Prevents glibc from hoarding memory via memory fragmentation. +#[inline] +pub fn limit_mmap_threshold(threshold: i32) { + unsafe { + mallopt(M_MMAP_THRESHOLD, threshold as c_int); + } +} diff --git a/src/process.rs b/src/process.rs index 1f58531a..2b6c4e0e 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,31 +1,88 @@ -use std::process::{exit, Command, Stdio}; +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 -use nix::sys::wait::waitpid; -use nix::unistd::{fork, ForkResult}; +#[cfg(all(feature = "smol", not(feature = "tokio")))] +use smol::io::AsyncReadExt; +use std::io; +use std::os::fd::OwnedFd; +use std::process::{Command, Stdio, exit}; +#[cfg(feature = "tokio")] +use tokio::io::AsyncReadExt; + +async fn read_from_pipe(read: OwnedFd) -> Option { + #[cfg(feature = "tokio")] + { + let mut read = tokio::net::unix::pipe::Receiver::from_owned_fd(read).unwrap(); + return read.read_u32().await.ok(); + } + + #[cfg(all(feature = "smol", not(feature = "tokio")))] + { + let mut read = smol::Async::new(std::fs::File::from(read)).unwrap(); + let mut bytes = [0; 4]; + read.read_exact(&mut bytes).await.ok()?; + return Some(u32::from_be_bytes(bytes)); + } + + #[cfg(not(any(feature = "tokio", feature = "smol")))] + { + use rustix::fd::AsFd; + let mut bytes = [0u8; 4]; + rustix::io::read(&read, &mut bytes).ok()?; + return Some(u32::from_be_bytes(bytes)); + } +} /// Performs a double fork with setsid to spawn and detach a command. -pub fn spawn(mut command: Command) { +#[cold] +pub async fn spawn(mut command: Command) -> Option { + // NOTE: Windows platform is not supported command .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - unsafe { - match fork() { - Ok(ForkResult::Parent { child }) => { - let _res = waitpid(Some(child), None); + // Handle Linux + #[cfg(all(unix, not(target_os = "macos")))] + let Ok((read, write)) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC) else { + return None; + }; + + // Handle macOS + #[cfg(target_os = "macos")] + let Ok((read, write)) = rustix::pipe::pipe() else { + return None; + }; + + match unsafe { libc::fork() } { + // Parent process + 1.. => { + // Drop copy of write end, then read PID from pipe + drop(write); + let pid = read_from_pipe(read).await; + // wait to prevent zombie + _ = rustix::process::wait(rustix::process::WaitOptions::empty()); + pid + } + + // Child process + 0 => { + let _res = rustix::process::setsid(); + if let Ok(child) = command.spawn() { + // Write PID to pipe + let _ = rustix::io::write(write, &child.id().to_be_bytes()); } - Ok(ForkResult::Child) => { - let _res = nix::unistd::setsid(); - let _res = command.spawn(); + exit(0) + } - exit(0); - } + ..=-1 => { + println!( + "failed to fork and spawn command: {}", + io::Error::last_os_error() + ); - Err(why) => { - println!("failed to fork and spawn command: {}", why.desc()); - } + None } } } diff --git a/src/scroll.rs b/src/scroll.rs new file mode 100644 index 00000000..b6d42378 --- /dev/null +++ b/src/scroll.rs @@ -0,0 +1,112 @@ +use iced::Task; +use iced::mouse::ScrollDelta; +use std::time::{Duration, Instant}; + +// Number of scroll pixels before changing workspace +const SCROLL_PIXELS: f32 = 24.0; + +// Timeout for scroll accumulation; older partial scroll is dropped +const SCROLL_TIMEOUT: Duration = Duration::from_millis(100); + +/// A scroll delta with discrete integer deltas +#[derive(Debug, Default, Clone, Copy)] +pub struct DiscreteScrollDelta { + pub x: isize, + pub y: isize, +} + +/// Helper for accumulating and converting pixel/line scrolls into and integer +/// delta between discrete options. +#[derive(Debug, Default)] +pub struct DiscreteScrollState { + x: Scroll, + y: Scroll, + rate_limit: Option, +} + +impl DiscreteScrollState { + /// Set a rate limit. If set, a call to `update()` will only not produce + /// values other than 1, -1, or 0 and a non-zero return value will not + /// occur more frequently than this duration. + pub fn rate_limit(mut self, rate_limit: Option) -> Self { + self.rate_limit = rate_limit; + self + } + + /// Reset, clearing any acculuated scroll events that haven't been + /// converted to discrete events yet. + pub fn reset(&mut self) { + self.x.reset(); + self.y.reset(); + } + + /// Accumulate delta with a timer + pub fn update(&mut self, delta: ScrollDelta) -> DiscreteScrollDelta { + let (x, y) = match delta { + ScrollDelta::Pixels { x, y } => (x / SCROLL_PIXELS, y / SCROLL_PIXELS), + ScrollDelta::Lines { x, y } => (x, y), + }; + + DiscreteScrollDelta { + x: self.x.update(x, self.rate_limit), + y: self.y.update(y, self.rate_limit), + } + } +} + +/// Scroll over a single axis +#[derive(Debug, Default)] +struct Scroll { + scroll: Option<(f32, Instant)>, + last_discrete: Option, +} + +impl Scroll { + fn reset(&mut self) { + *self = Default::default(); + } + + fn update(&mut self, delta: f32, rate_limit: Option) -> isize { + if delta == 0. { + // If delta is 0, scroll is on other axis; clear accumulated scroll + self.reset(); + 0 + } else { + let previous_scroll = if let Some((scroll, last_scroll_time)) = self.scroll { + if last_scroll_time.elapsed() > SCROLL_TIMEOUT { + 0. + } else { + scroll + } + } else { + 0. + }; + + let scroll = previous_scroll + delta; + + if self + .last_discrete + .is_some_and(|time| time.elapsed() < rate_limit.unwrap_or(Duration::ZERO)) + { + // If rate limit is hit, continute accumulating, but don't return + // a discrete event yet. + self.scroll = Some((scroll, Instant::now())); + 0 + } else { + // Return integer part of scroll, and keep remainder + self.scroll = Some((scroll.fract(), Instant::now())); + let mut discrete = scroll.trunc() as isize; + if discrete != 0 { + self.last_discrete = Some(Instant::now()); + } + if rate_limit.is_some() { + // If we are rate limiting, don't return multiple discrete events + // at once; drop extras. + discrete.signum() + } else { + discrete + } + } + } + } +} diff --git a/src/surface/action.rs b/src/surface/action.rs new file mode 100644 index 00000000..50e2b4a9 --- /dev/null +++ b/src/surface/action.rs @@ -0,0 +1,227 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use super::Action; +#[cfg(feature = "winit")] +use crate::Application; + +use iced::window; +use std::{any::Any, sync::Arc}; + +/// Used to produce a destroy popup message from within a widget. +#[cfg(all(feature = "wayland", target_os = "linux"))] +#[must_use] +pub fn destroy_popup(id: iced_core::window::Id) -> Action { + Action::DestroyPopup(id) +} + +#[cfg(all(feature = "wayland", target_os = "linux"))] +#[must_use] +pub fn destroy_subsurface(id: iced_core::window::Id) -> Action { + Action::DestroySubsurface(id) +} + +#[cfg(all(feature = "wayland", target_os = "linux"))] +#[must_use] +pub fn destroy_window(id: iced_core::window::Id) -> Action { + Action::DestroyWindow(id) +} + +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[must_use] +pub fn app_window( + settings: impl Fn(&mut App) -> window::Settings + Send + Sync + 'static, + view: Option< + Box< + dyn for<'a> Fn(&'a App) -> crate::Element<'a, crate::Action> + + Send + + Sync + + 'static, + >, + >, +) -> (window::Id, Action) { + let id = window::Id::unique(); + + let boxed: Box window::Settings + Send + Sync + 'static> = + Box::new(settings); + let boxed: Box = Box::new(boxed); + + ( + id, + Action::AppWindow( + id, + Arc::new(boxed), + view.map(|view| { + let boxed: Box = Box::new(view); + Arc::new(boxed) + }), + ), + ) +} + +/// Used to create a window message from within a widget. +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[must_use] +pub fn simple_window( + settings: impl Fn() -> window::Settings + Send + Sync + 'static, + view: Option< + impl Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, + >, +) -> (window::Id, Action) { + let id = window::Id::unique(); + + let boxed: Box window::Settings + Send + Sync + 'static> = Box::new(settings); + let boxed: Box = Box::new(boxed); + + ( + id, + Action::Window( + id, + Arc::new(boxed), + view.map(|view| { + let boxed: Box< + dyn Fn() -> crate::Element<'static, crate::Action> + + Send + + Sync + + 'static, + > = Box::new(view); + let boxed: Box = Box::new(boxed); + Arc::new(boxed) + }), + ), + ) +} + +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[must_use] +pub fn app_popup( + settings: impl Fn(&mut App) -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + view: Option< + Box< + dyn for<'a> Fn(&'a App) -> crate::Element<'a, crate::Action> + + Send + + Sync + + 'static, + >, + >, +) -> Action { + let boxed: Box< + dyn Fn(&mut App) -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + > = Box::new(settings); + let boxed: Box = Box::new(boxed); + + Action::AppPopup( + Arc::new(boxed), + view.map(|view| { + let boxed: Box = Box::new(view); + Arc::new(boxed) + }), + ) +} + +/// Used to create a subsurface message from within a widget. +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[must_use] +pub fn simple_subsurface( + settings: impl Fn() -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + + Send + + Sync + + 'static, + view: Option< + Box crate::Element<'static, crate::Action> + Send + Sync + 'static>, + >, +) -> Action { + let boxed: Box< + dyn Fn() -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + + Send + + Sync + + 'static, + > = Box::new(settings); + let boxed: Box = Box::new(boxed); + + Action::Subsurface( + Arc::new(boxed), + view.map(|view| { + let boxed: Box = Box::new(view); + Arc::new(boxed) + }), + ) +} + +/// Used to create a popup message from within a widget. +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[must_use] +pub fn simple_popup( + settings: impl Fn() -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + view: Option< + impl Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, + >, +) -> Action { + let boxed: Box< + dyn Fn() -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + > = Box::new(settings); + let boxed: Box = Box::new(boxed); + + Action::Popup( + Arc::new(boxed), + view.map(|view| { + let boxed: Box< + dyn Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, + > = Box::new(view); + let boxed: Box = Box::new(boxed); + Arc::new(boxed) + }), + ) +} + +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[must_use] +pub fn subsurface( + settings: impl Fn( + &mut App, + ) + -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + + Send + + Sync + + 'static, + // XXX Boxed trait object is required for less cumbersome type inference, but we box it anyways. + view: Option< + Box< + dyn for<'a> Fn(&'a App) -> crate::Element<'a, crate::Action> + + Send + + Sync + + 'static, + >, + >, +) -> Action { + let boxed: Box< + dyn Fn( + &mut App, + ) + -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + + Send + + Sync + + 'static, + > = Box::new(settings); + let boxed: Box = Box::new(boxed); + + Action::AppSubsurface( + Arc::new(boxed), + view.map(|view| { + let boxed: Box = Box::new(view); + Arc::new(boxed) + }), + ) +} diff --git a/src/surface/mod.rs b/src/surface/mod.rs new file mode 100644 index 00000000..0dad6459 --- /dev/null +++ b/src/surface/mod.rs @@ -0,0 +1,118 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +pub mod action; + +use iced::Limits; +use iced::Size; +use iced::Task; +use std::future::Future; +use std::sync::Arc; + +/// Ignore this message in your application. It will be intercepted. +#[derive(Clone)] +pub enum Action { + /// Create a subsurface with a view function accepting the App as a parameter + AppSubsurface( + std::sync::Arc>, + Option>>, + ), + /// Create a subsurface with a view function + Subsurface( + std::sync::Arc>, + Option>>, + ), + /// Destroy a subsurface with a view function + DestroySubsurface(iced::window::Id), + /// Create a popup with a view function accepting the App as a parameter + AppPopup( + std::sync::Arc>, + Option>>, + ), + /// Create a popup + Popup( + std::sync::Arc>, + Option>>, + ), + /// Destroy a subsurface with a view function + DestroyPopup(iced::window::Id), + /// Destroys the global tooltip popup subsurface + DestroyTooltipPopup, + + /// Create a window with a view function accepting the App as a parameter + AppWindow( + iced::window::Id, + std::sync::Arc>, + Option>>, + ), + /// Create a window with a view function + Window( + iced::window::Id, + std::sync::Arc>, + Option>>, + ), + /// Destroy a window + DestroyWindow(iced::window::Id), + + /// Responsive menu bar update + ResponsiveMenuBar { + /// Id of the menu bar + menu_bar: crate::widget::Id, + /// Limits of the menu bar + limits: Limits, + /// Requested Full Size for expanded menu bar + size: Size, + }, + Ignore, + Task(Arc Task + Send + Sync>), +} + +impl std::fmt::Debug for Action { + #[cold] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AppSubsurface(arg0, arg1) => f + .debug_tuple("AppSubsurface") + .field(arg0) + .field(arg1) + .finish(), + Self::Subsurface(arg0, arg1) => { + f.debug_tuple("Subsurface").field(arg0).field(arg1).finish() + } + Self::DestroySubsurface(arg0) => { + f.debug_tuple("DestroySubsurface").field(arg0).finish() + } + Self::AppPopup(arg0, arg1) => { + f.debug_tuple("AppPopup").field(arg0).field(arg1).finish() + } + Self::Popup(arg0, arg1) => f.debug_tuple("Popup").field(arg0).field(arg1).finish(), + Self::DestroyPopup(arg0) => f.debug_tuple("DestroyPopup").field(arg0).finish(), + Self::DestroyTooltipPopup => f.debug_tuple("DestroyTooltipPopup").finish(), + Self::ResponsiveMenuBar { + menu_bar, + limits, + size, + } => f + .debug_struct("ResponsiveMenuBar") + .field("menu_bar", menu_bar) + .field("limits", limits) + .field("size", size) + .finish(), + Self::Ignore => write!(f, "Ignore"), + Self::AppWindow(id, arg0, arg1) => f + .debug_tuple("AppWindow") + .field(id) + .field(arg0) + .field(arg1) + .finish(), + Self::Window(id, arg0, arg1) => f + .debug_tuple("Window") + .field(id) + .field(arg0) + .field(arg1) + .finish(), + Self::DestroyWindow(arg0) => f.debug_tuple("DestroyWindow").field(arg0).finish(), + Self::Task(_) => f.debug_tuple("Future").finish(), + } + } +} diff --git a/src/task.rs b/src/task.rs new file mode 100644 index 00000000..f155706e --- /dev/null +++ b/src/task.rs @@ -0,0 +1,37 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Create asynchronous actions to be performed in the background. + +use futures::stream::{Stream, StreamExt}; +use std::future::Future; + +/// Yields a task which contains a batch of tasks. +pub fn batch, Y: Send + 'static>( + tasks: impl IntoIterator>, +) -> iced::Task { + iced::Task::batch(tasks).map(Into::into) +} + +/// Yields a task which will run the future on the runtime executor. +pub fn future, Y: 'static>( + future: impl Future + Send + 'static, +) -> iced::Task { + iced::Task::future(async move { future.await.into() }) +} + +/// Yields a task which will return a message. +pub fn message, Y: 'static>(message: X) -> iced::Task { + future(async move { message.into() }) +} + +/// Yields a task which will run a stream on the runtime executor. +pub fn stream + 'static, Y: 'static>( + stream: impl Stream + Send + 'static, +) -> iced::Task { + iced::Task::stream(stream.map(Into::into)) +} + +pub fn none() -> iced::Task { + iced::Task::none() +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index db691170..093bac05 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -3,92 +3,145 @@ //! Contains the [`Theme`] type and its widget stylesheet implementations. +#[cfg(feature = "xdg-portal")] +pub mod portal; pub mod style; -use cosmic_theme::ThemeMode; -pub use style::*; -use cosmic_config::config_subscription; use cosmic_config::CosmicConfigEntry; +use cosmic_config::config_subscription; use cosmic_theme::Component; use cosmic_theme::LayeredTheme; +use cosmic_theme::Spacing; +use cosmic_theme::ThemeMode; use iced_futures::Subscription; -use palette::Srgba; -use std::cell::RefCell; -use std::sync::Arc; +use iced_runtime::{Appearance, DefaultStyle}; +use std::sync::{Arc, LazyLock, Mutex}; +pub use style::*; pub type CosmicColor = ::palette::rgb::Srgba; -pub type CosmicComponent = cosmic_theme::Component; -pub type CosmicTheme = cosmic_theme::Theme; +pub type CosmicComponent = cosmic_theme::Component; +pub type CosmicTheme = cosmic_theme::Theme; -lazy_static::lazy_static! { - pub static ref COSMIC_DARK: CosmicTheme = CosmicTheme::dark_default(); - pub static ref COSMIC_HC_DARK: CosmicTheme = CosmicTheme::high_contrast_dark_default(); - pub static ref COSMIC_LIGHT: CosmicTheme = CosmicTheme::light_default(); - pub static ref COSMIC_HC_LIGHT: CosmicTheme = CosmicTheme::high_contrast_light_default(); - pub static ref TRANSPARENT_COMPONENT: Component = Component { - base: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - hover: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - pressed: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - selected: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - selected_text: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - focus: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - disabled: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - on: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - on_disabled: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - divider: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - border: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - disabled_border: CosmicColor::new(0.0, 0.0, 0.0, 0.0), - }; -} +pub static COSMIC_DARK: LazyLock = LazyLock::new(CosmicTheme::dark_default); -thread_local! { - pub(crate) static THEME: RefCell = RefCell::new(Theme { theme_type: ThemeType::Dark, layer: cosmic_theme::Layer::Background }); -} +pub static COSMIC_HC_DARK: LazyLock = + LazyLock::new(CosmicTheme::high_contrast_dark_default); + +pub static COSMIC_LIGHT: LazyLock = LazyLock::new(CosmicTheme::light_default); + +pub static COSMIC_HC_LIGHT: LazyLock = + LazyLock::new(CosmicTheme::high_contrast_light_default); + +pub static TRANSPARENT_COMPONENT: LazyLock = LazyLock::new(|| Component { + base: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + hover: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + pressed: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + selected: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + selected_text: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + focus: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + disabled: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + on: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + on_disabled: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + divider: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + border: CosmicColor::new(0.0, 0.0, 0.0, 0.0), + disabled_border: CosmicColor::new(0.0, 0.0, 0.0, 0.0), +}); + +pub(crate) static THEME: Mutex = Mutex::new(Theme { + theme_type: ThemeType::Dark, + layer: cosmic_theme::Layer::Background, +}); /// Currently-defined theme. +#[inline] +#[allow(clippy::missing_panics_doc)] pub fn active() -> Theme { - THEME.with(|theme| theme.borrow().clone()) + THEME.lock().unwrap().clone() } /// Currently-defined theme type. +#[inline] +#[allow(clippy::missing_panics_doc)] pub fn active_type() -> ThemeType { - THEME.with(|theme| theme.borrow().theme_type.clone()) + THEME.lock().unwrap().theme_type.clone() +} + +/// Preferred interface spacing parameters defined by the active theme. +#[inline] +pub fn spacing() -> Spacing { + active().cosmic().spacing } /// Whether the active theme has a dark preference. +#[inline] #[must_use] pub fn is_dark() -> bool { active_type().is_dark() } /// Whether the active theme is high contrast. +#[inline] #[must_use] pub fn is_high_contrast() -> bool { active_type().is_high_contrast() } -/// Watches for changes to the system's theme preference. -pub fn subscription(id: u64, is_dark: bool) -> Subscription { - config_subscription::<_, crate::cosmic_theme::Theme>( - (id, is_dark), - if is_dark { - cosmic_theme::DARK_THEME_ID - } else { - cosmic_theme::LIGHT_THEME_ID - } - .into(), - crate::cosmic_theme::Theme::::version(), - ) - .map(|(_, res)| { - let theme = res.unwrap_or_else(|(errors, theme)| { - for err in errors { - tracing::error!("{:?}", err); - } - theme - }); +// /// Watches for changes to the system's theme preference. +// #[cold] +// pub fn subscription(is_dark: bool) -> Subscription { +// config_subscription::<_, crate::cosmic_theme::Theme>( +// ( +// std::any::TypeId::of::(), +// is_dark, +// ), +// if is_dark { +// cosmic_theme::DARK_THEME_ID +// } else { +// cosmic_theme::LIGHT_THEME_ID +// } +// .into(), +// crate::cosmic_theme::Theme::VERSION, +// ) +// .map(|res| { +// for error in res.errors.into_iter().filter(cosmic_config::Error::is_err) { +// tracing::error!( +// ?error, +// "error while watching system theme preference changes" +// ); +// } - Theme::system(Arc::new(theme)) - }) +// Theme::system(Arc::new(res.config)) +// }) +// } + +pub fn system_dark() -> Theme { + let Ok(helper) = crate::cosmic_theme::Theme::dark_config() else { + return Theme::dark(); + }; + + let t = crate::cosmic_theme::Theme::get_entry(&helper).unwrap_or_else(|(errors, theme)| { + for error in errors.into_iter().filter(cosmic_config::Error::is_err) { + tracing::error!(?error, "error loading system dark theme"); + } + theme + }); + + Theme::system(Arc::new(t)) +} + +pub fn system_light() -> Theme { + let Ok(helper) = crate::cosmic_theme::Theme::light_config() else { + return Theme::light(); + }; + + let t = crate::cosmic_theme::Theme::get_entry(&helper).unwrap_or_else(|(errors, theme)| { + for error in errors.into_iter().filter(cosmic_config::Error::is_err) { + tracing::error!(?error, "error loading system light theme"); + } + theme + }); + + Theme::system(Arc::new(t)) } /// Loads the preferred system theme from `cosmic-config`. @@ -100,25 +153,11 @@ pub fn system_preference() -> Theme { let Ok(is_dark) = ThemeMode::is_dark(&mode_config) else { return Theme::dark(); }; - - let helper = if is_dark { - crate::cosmic_theme::Theme::::dark_config() + if is_dark { + system_dark() } else { - crate::cosmic_theme::Theme::::light_config() - }; - - let Ok(helper) = helper else { - return Theme::dark(); - }; - - let t = crate::cosmic_theme::Theme::get_entry(&helper).unwrap_or_else(|(errors, theme)| { - for err in errors { - tracing::error!("{:?}", err); - } - theme - }); - - Theme::system(Arc::new(t)) + system_light() + } } #[must_use] @@ -130,27 +169,41 @@ pub enum ThemeType { HighContrastDark, HighContrastLight, Custom(Arc), - System(Arc), + System { + prefer_dark: Option, + theme: Arc, + }, } impl ThemeType { /// Whether the theme has a dark preference. #[must_use] + #[inline] pub fn is_dark(&self) -> bool { match self { Self::Dark | Self::HighContrastDark => true, Self::Light | Self::HighContrastLight => false, - Self::Custom(theme) | Self::System(theme) => theme.is_dark, + Self::Custom(theme) | Self::System { theme, .. } => theme.is_dark, } } /// Whether the theme has a high contrast. + #[inline] #[must_use] pub fn is_high_contrast(&self) -> bool { match self { Self::Dark | Self::Light => false, Self::HighContrastDark | Self::HighContrastLight => true, - Self::Custom(theme) | Self::System(theme) => theme.is_high_contrast, + Self::Custom(theme) | Self::System { theme, .. } => theme.is_high_contrast, + } + } + + #[inline] + /// Prefer dark or light theme. + /// If `None`, the system preference is used. + pub fn prefer_dark(&mut self, new_prefer_dark: Option) { + if let Self::System { prefer_dark, .. } = self { + *prefer_dark = new_prefer_dark; } } } @@ -163,17 +216,18 @@ pub struct Theme { } impl Theme { - #[must_use] - pub fn cosmic(&self) -> &cosmic_theme::Theme { + #[inline] + pub fn cosmic(&self) -> &cosmic_theme::Theme { match self.theme_type { ThemeType::Dark => &COSMIC_DARK, ThemeType::Light => &COSMIC_LIGHT, ThemeType::HighContrastDark => &COSMIC_HC_DARK, ThemeType::HighContrastLight => &COSMIC_HC_LIGHT, - ThemeType::Custom(ref t) | ThemeType::System(ref t) => t.as_ref(), + ThemeType::Custom(ref t) | ThemeType::System { theme: ref t, .. } => t.as_ref(), } } + #[inline] pub fn dark() -> Self { Self { theme_type: ThemeType::Dark, @@ -181,6 +235,7 @@ impl Theme { } } + #[inline] pub fn light() -> Self { Self { theme_type: ThemeType::Light, @@ -188,6 +243,7 @@ impl Theme { } } + #[inline] pub fn dark_hc() -> Self { Self { theme_type: ThemeType::HighContrastDark, @@ -195,6 +251,7 @@ impl Theme { } } + #[inline] pub fn light_hc() -> Self { Self { theme_type: ThemeType::HighContrastLight, @@ -202,6 +259,7 @@ impl Theme { } } + #[inline] pub fn custom(theme: Arc) -> Self { Self { theme_type: ThemeType::Custom(theme), @@ -209,17 +267,21 @@ impl Theme { } } + #[inline] pub fn system(theme: Arc) -> Self { Self { - theme_type: ThemeType::System(theme), + theme_type: ThemeType::System { + theme, + prefer_dark: None, + }, ..Default::default() } } + #[inline] /// get current container /// can be used in a component that is intended to be a child of a `CosmicContainer` - #[must_use] - pub fn current_container(&self) -> &cosmic_theme::Container { + pub fn current_container(&self) -> &cosmic_theme::Container { match self.layer { cosmic_theme::Layer::Background => &self.cosmic().background, cosmic_theme::Layer::Primary => &self.cosmic().primary, @@ -227,6 +289,7 @@ impl Theme { } } + #[inline] /// set the theme pub fn set_theme(&mut self, theme: ThemeType) { self.theme_type = theme; @@ -234,7 +297,19 @@ impl Theme { } impl LayeredTheme for Theme { + #[inline] fn set_layer(&mut self, layer: cosmic_theme::Layer) { self.layer = layer; } } + +impl DefaultStyle for Theme { + fn default_style(&self) -> Appearance { + let cosmic = self.cosmic(); + Appearance { + icon_color: cosmic.on_bg_color().into(), + background_color: cosmic.bg_color().into(), + text_color: cosmic.on_bg_color().into(), + } + } +} diff --git a/src/theme/portal.rs b/src/theme/portal.rs new file mode 100644 index 00000000..0154ff58 --- /dev/null +++ b/src/theme/portal.rs @@ -0,0 +1,103 @@ +use ashpd::desktop::Color; +use ashpd::desktop::settings::{ColorScheme, Contrast}; +use iced::futures::{self, FutureExt, SinkExt, StreamExt, select}; +use iced_futures::stream; +use tracing::error; + +#[derive(Debug, Clone)] +pub enum Desktop { + Accent(Color), + ColorScheme(ColorScheme), + Contrast(Contrast), +} + +#[cold] +pub fn desktop_settings() -> iced_futures::Subscription { + iced_futures::Subscription::run(|| { + stream::channel(10, |mut tx: futures::channel::mpsc::Sender| { + async move { + let mut attempts = 0; + loop { + let Ok(settings) = ashpd::desktop::settings::Settings::new().await else { + error!("Failed to create the settings proxy"); + #[cfg(feature = "tokio")] + ::tokio::time::sleep(::tokio::time::Duration::from_secs( + 2_u64.pow(attempts), + )) + .await; + #[cfg(not(feature = "tokio"))] + { + futures::future::pending::<()>().await; + unreachable!(); + } + attempts += 1; + continue; + }; + + match settings.color_scheme().await { + Ok(color_scheme) => { + let _ = tx.send(Desktop::ColorScheme(color_scheme)).await; + } + Err(err) => error!("Failed to get the color scheme {err:?}"), + }; + match settings.contrast().await { + Ok(contrast) => { + let _ = tx.send(Desktop::Contrast(contrast)).await; + } + Err(err) => error!("Failed to get the contrast {err:?}"), + }; + + let mut color_scheme_stream = + settings.receive_color_scheme_changed().await.ok(); + if color_scheme_stream.is_none() { + error!("Failed to receive color scheme changes"); + } + + let mut contrast_stream = settings.receive_contrast_changed().await.ok(); + if contrast_stream.is_none() { + error!("Failed to receive contrast changes"); + } + + loop { + if color_scheme_stream.is_none() && contrast_stream.is_none() { + break; + } + let next_color_scheme = async { + if let Some(s) = color_scheme_stream.as_mut() { + return s.next().await; + } + futures::future::pending().await + }; + + let next_contrast = async { + if let Some(s) = contrast_stream.as_mut() { + return s.next().await; + } + futures::future::pending().await + }; + + select! { + s = next_color_scheme.fuse() => { + if let Some(s) = s { + _ = tx.send(Desktop::ColorScheme(s)).await; + } else { + color_scheme_stream = None; + } + }, + + c = next_contrast.fuse() => { + if let Some(c) = c { + _ = tx.send(Desktop::Contrast(c)).await; + } else { + contrast_stream = None; + } + } + }; + // Reset the attempts counter if we successfully received a change + attempts = 0; + } + } + } + }) + }) +} diff --git a/src/theme/style/button.rs b/src/theme/style/button.rs index ec263eb5..bb52d9a6 100644 --- a/src/theme/style/button.rs +++ b/src/theme/style/button.rs @@ -5,47 +5,52 @@ use cosmic_theme::Component; use iced_core::{Background, Color}; -use palette::{rgb::Rgb, Alpha}; use crate::{ theme::TRANSPARENT_COMPONENT, - widget::button::{Appearance, StyleSheet}, + widget::button::{Catalog, Style}, }; #[derive(Default)] pub enum Button { + AppletIcon, + AppletMenu, Custom { - active: Box Appearance>, - disabled: Box Appearance>, - hovered: Box Appearance>, - pressed: Box Appearance>, + active: Box Style>, + disabled: Box Style>, + hovered: Box Style>, + pressed: Box Style>, }, Destructive, - Link, + HeaderBar, Icon, IconVertical, Image, + Link, + ListItem([f32; 4]), + MenuFolder, + MenuItem, + MenuRoot, + NavToggle, #[default] Standard, Suggested, Text, Transparent, - AppletMenu, - AppletIcon, - MenuRoot, - MenuItem, } pub fn appearance( theme: &crate::Theme, focused: bool, + selected: bool, + disabled: bool, style: &Button, - color: impl Fn(&Component>) -> (Color, Option, Option), -) -> Appearance { + color: impl Fn(&Component) -> (Color, Option, Option), +) -> Style { let cosmic = theme.cosmic(); let mut corner_radii = &cosmic.corner_radii.radius_xl; - let mut appearance = Appearance::new(); - + let mut appearance = Style::new(); + let hc = theme.theme_type.is_high_contrast(); match style { Button::Standard | Button::Text @@ -63,37 +68,49 @@ pub fn appearance( let (background, text, icon) = color(style_component); appearance.background = Some(Background::Color(background)); - appearance.text_color = text; - appearance.icon_color = icon; + if !matches!(style, Button::Standard) { + appearance.text_color = text; + appearance.icon_color = icon; + } else if hc { + appearance.border_color = style_component.border.into(); + appearance.border_width = 1.; + } } - Button::Icon | Button::IconVertical => { + Button::Icon | Button::IconVertical | Button::HeaderBar | Button::NavToggle => { if matches!(style, Button::IconVertical) { corner_radii = &cosmic.corner_radii.radius_m; + if selected { + appearance.overlay = Some(Background::Color(Color::from( + cosmic.icon_button.selected_state_color(), + ))); + } + } + if matches!(style, Button::NavToggle) { + corner_radii = &cosmic.corner_radii.radius_s; } let (background, text, icon) = color(&cosmic.icon_button); appearance.background = Some(Background::Color(background)); - appearance.text_color = text; - appearance.icon_color = icon; - - if focused { - appearance.text_color = Some(cosmic.accent.on.into()); - appearance.icon_color = Some(cosmic.accent.on.into()); - } + // Only override icon button colors when it is disabled + appearance.icon_color = if disabled { icon } else { None }; + appearance.text_color = if disabled { text } else { None }; } Button::Image => { appearance.background = None; - appearance.text_color = Some(cosmic.accent.base.into()); + appearance.text_color = Some(cosmic.accent_text_color().into()); appearance.icon_color = Some(cosmic.accent.base.into()); corner_radii = &cosmic.corner_radii.radius_s; appearance.border_radius = (*corner_radii).into(); - if focused { + if focused || selected { appearance.border_width = 2.0; appearance.border_color = cosmic.accent.base.into(); + } else if hc { + appearance.border_color = theme.current_container().component.divider.into(); + appearance.border_width = 1.; } return appearance; @@ -101,8 +118,8 @@ pub fn appearance( Button::Link => { appearance.background = None; - appearance.icon_color = Some(cosmic.accent.base.into()); - appearance.text_color = Some(cosmic.accent.base.into()); + appearance.icon_color = Some(cosmic.accent_text_color().into()); + appearance.text_color = Some(cosmic.accent_text_color().into()); corner_radii = &cosmic.corner_radii.radius_0; } @@ -122,19 +139,42 @@ pub fn appearance( appearance.icon_color = Some(cosmic.background.on.into()); appearance.text_color = Some(cosmic.background.on.into()); } - Button::MenuRoot => { - appearance.background = None; - appearance.icon_color = Some(cosmic.accent.base.into()); - appearance.text_color = Some(cosmic.accent.base.into()); + Button::MenuFolder => { + // Menu folders cannot be disabled, ignore customized icon and text color + let component = &cosmic.background.component; + let (background, _, _) = color(component); + appearance.background = Some(Background::Color(background)); + appearance.icon_color = Some(component.on.into()); + appearance.text_color = Some(component.on.into()); + corner_radii = &cosmic.corner_radii.radius_s; + } + Button::ListItem(radii) => { + corner_radii = radii; + let (background, text, icon) = color(&cosmic.background.component); + + if selected { + appearance.background = + Some(Background::Color(cosmic.primary.component.hover.into())); + appearance.icon_color = Some(cosmic.accent.base.into()); + appearance.text_color = Some(cosmic.accent_text_color().into()); + } else { + appearance.background = Some(Background::Color(background)); + appearance.icon_color = icon; + appearance.text_color = text; + } } Button::MenuItem => { - let (background, _, _) = color(&cosmic.background.component); + let (background, text, icon) = color(&cosmic.background.component); appearance.background = Some(Background::Color(background)); - - appearance.icon_color = Some(cosmic.background.on.into()); - appearance.text_color = Some(cosmic.background.on.into()); + appearance.icon_color = icon; + appearance.text_color = text; corner_radii = &cosmic.corner_radii.radius_s; } + Button::MenuRoot => { + appearance.background = None; + appearance.icon_color = None; + appearance.text_color = None; + } } appearance.border_radius = (*corner_radii).into(); @@ -149,31 +189,43 @@ pub fn appearance( appearance } -impl StyleSheet for crate::Theme { - type Style = Button; +impl Catalog for crate::Theme { + type Class = Button; - fn active(&self, focused: bool, style: &Self::Style) -> Appearance { + fn active(&self, focused: bool, selected: bool, style: &Self::Class) -> Style { if let Button::Custom { active, .. } = style { return active(focused, self); } - appearance(self, focused, style, |component| { - let text_color = if let Button::Icon | Button::IconVertical = style { - None + let mut s = appearance(self, focused, selected, false, style, move |component| { + let text_color = if matches!( + style, + Button::Icon | Button::IconVertical | Button::HeaderBar + ) && selected + { + Some(self.cosmic().accent_text_color().into()) } else { Some(component.on.into()) }; (component.base.into(), text_color, text_color) - }) + }); + + if let Button::ListItem(_) = style { + if !selected { + s.background = None; + } + } + + s } - fn disabled(&self, style: &Self::Style) -> Appearance { + fn disabled(&self, style: &Self::Class) -> Style { if let Button::Custom { disabled, .. } = style { return disabled(self); } - appearance(self, false, style, |component| { + appearance(self, false, false, true, style, |component| { let mut background = Color::from(component.base); background.a *= 0.5; ( @@ -184,40 +236,62 @@ impl StyleSheet for crate::Theme { }) } - fn drop_target(&self, style: &Self::Style) -> Appearance { - self.active(false, style) + fn drop_target(&self, style: &Self::Class) -> Style { + self.active(false, false, style) } - fn hovered(&self, focused: bool, style: &Self::Style) -> Appearance { + fn hovered(&self, focused: bool, selected: bool, style: &Self::Class) -> Style { if let Button::Custom { hovered, .. } = style { return hovered(focused, self); } - appearance( + let mut s = appearance( self, focused || matches!(style, Button::Image), + selected, + false, style, |component| { - ( - component.hover.into(), - Some(component.on.into()), - Some(component.on.into()), - ) + let text_color = if matches!( + style, + Button::Icon | Button::IconVertical | Button::HeaderBar + ) && selected + { + Some(self.cosmic().accent_text_color().into()) + } else { + Some(component.on.into()) + }; + + (component.hover.into(), text_color, text_color) }, - ) + ); + + if let Button::ListItem(_) = style { + if !selected { + s.background = None; + } + } + + s } - fn pressed(&self, focused: bool, style: &Self::Style) -> Appearance { + fn pressed(&self, focused: bool, selected: bool, style: &Self::Class) -> Style { if let Button::Custom { pressed, .. } = style { return pressed(focused, self); } - appearance(self, focused, style, |component| { - ( - component.pressed.into(), - Some(component.on.into()), - Some(component.on.into()), - ) + appearance(self, focused, selected, false, style, |component| { + let text_color = if matches!( + style, + Button::Icon | Button::IconVertical | Button::HeaderBar + ) && selected + { + Some(self.cosmic().accent_text_color().into()) + } else { + Some(component.on.into()) + }; + + (component.pressed.into(), text_color, text_color) }) } diff --git a/src/theme/style/dropdown.rs b/src/theme/style/dropdown.rs index 17a8b9fe..cc89a399 100644 --- a/src/theme/style/dropdown.rs +++ b/src/theme/style/dropdown.rs @@ -1,8 +1,8 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use crate::widget::dropdown; use crate::Theme; +use crate::widget::dropdown; use iced::{Background, Color}; impl dropdown::menu::StyleSheet for Theme { @@ -15,13 +15,13 @@ impl dropdown::menu::StyleSheet for Theme { text_color: cosmic.on_bg_color().into(), background: Background::Color(cosmic.background.component.base.into()), border_width: 0.0, - border_radius: 16.0.into(), + border_radius: cosmic.corner_radii.radius_m.into(), border_color: Color::TRANSPARENT, hovered_text_color: cosmic.on_bg_color().into(), hovered_background: Background::Color(cosmic.primary.component.hover.into()), - selected_text_color: cosmic.accent.base.into(), + selected_text_color: cosmic.accent_text_color().into(), selected_background: Background::Color(cosmic.primary.component.hover.into()), description_color: cosmic.primary.component.on_disabled.into(), diff --git a/src/theme/style/iced.rs b/src/theme/style/iced.rs index d4868618..aa6f4b33 100644 --- a/src/theme/style/iced.rs +++ b/src/theme/style/iced.rs @@ -3,57 +3,47 @@ //! Contains stylesheet implementations for widgets native to iced. -use crate::theme::{CosmicComponent, Theme, TRANSPARENT_COMPONENT}; +use crate::theme::{CosmicComponent, TRANSPARENT_COMPONENT, Theme}; use cosmic_theme::composite::over; -use iced_core::gradient::Linear; -use iced_core::BorderRadius; -use iced_core::Radians; -use iced_core::{Background, Color}; -use iced_style::application; -use iced_style::button as iced_button; -use iced_style::checkbox; -use iced_style::container; -use iced_style::menu; -use iced_style::pane_grid; -use iced_style::pick_list; -use iced_style::progress_bar; -use iced_style::radio; -use iced_style::rule; -use iced_style::scrollable; -use iced_style::slider; -use iced_style::slider::Rail; -use iced_style::svg; -use iced_style::text_input; -use iced_style::toggler; -use std::f32::consts::PI; +use iced::{ + overlay::menu, + theme::Base, + widget::{ + button as iced_button, checkbox as iced_checkbox, combo_box, container as iced_container, + pane_grid, pick_list, progress_bar, radio, rule, scrollable, + slider::{self, Rail}, + svg, toggler, + }, +}; +use iced_core::{Background, Border, Color, Shadow, Vector}; +use iced_widget::{pane_grid::Highlight, scrollable::AutoScroll, text_editor, text_input}; +use palette::WithAlpha; use std::rc::Rc; -#[derive(Default)] -pub enum Application { - #[default] - Default, - Custom(Box application::Appearance>), -} +pub mod application { + use crate::Theme; + use iced_runtime::Appearance; -impl Application { - pub fn custom application::Appearance + 'static>(f: F) -> Self { - Self::Custom(Box::new(f)) + #[derive(Default)] + pub enum Application { + #[default] + Default, + Custom(Box Appearance>), } -} -impl application::StyleSheet for Theme { - type Style = Application; + impl Application { + pub fn custom Appearance + 'static>(f: F) -> Self { + Self::Custom(Box::new(f)) + } + } - fn appearance(&self, style: &Self::Style) -> application::Appearance { - let cosmic = self.cosmic(); + pub fn style(theme: &Theme) -> iced::theme::Style { + let cosmic = theme.cosmic(); - match style { - Application::Default => application::Appearance { - icon_color: cosmic.bg_color().into(), - background_color: cosmic.bg_color().into(), - text_color: cosmic.on_bg_color().into(), - }, - Application::Custom(f) => f(self), + iced::theme::Style { + background_color: cosmic.bg_color().into(), + text_color: cosmic.on_bg_color().into(), + icon_color: cosmic.on_bg_color().into(), } } } @@ -72,16 +62,94 @@ pub enum Button { LinkActive, Transparent, Card, - Custom { - active: Box iced_button::Appearance>, - hover: Box iced_button::Appearance>, - }, + Custom(Box iced_button::Style>), +} + +impl iced_button::Catalog for Theme { + type Class<'a> = Button; + + fn default<'a>() -> Self::Class<'a> { + Button::default() + } + + fn style(&self, class: &Self::Class<'_>, status: iced_button::Status) -> iced_button::Style { + if let Button::Custom(f) = class { + return f(self, status); + } + let cosmic = self.cosmic(); + let corner_radii = &cosmic.corner_radii; + let component = class.cosmic(self); + + let mut appearance = iced_button::Style { + border_radius: match class { + Button::Link => corner_radii.radius_0.into(), + Button::Card => corner_radii.radius_xs.into(), + _ => corner_radii.radius_xl.into(), + }, + border: Border { + radius: match class { + Button::Link => corner_radii.radius_0.into(), + Button::Card => corner_radii.radius_xs.into(), + _ => corner_radii.radius_xl.into(), + }, + ..Default::default() + }, + background: match class { + Button::Link | Button::Text => None, + Button::LinkActive => Some(Background::Color(component.divider.into())), + _ => Some(Background::Color(component.base.into())), + }, + text_color: match class { + Button::Link | Button::LinkActive => component.base.into(), + _ => component.on.into(), + }, + ..iced_button::Style::default() + }; + + match status { + iced_button::Status::Active => {} + iced_button::Status::Hovered => { + appearance.background = match class { + Button::Link => None, + Button::LinkActive => Some(Background::Color(component.divider.into())), + _ => Some(Background::Color(component.hover.into())), + }; + } + iced_button::Status::Pressed => { + appearance.background = match class { + Button::Link => None, + Button::LinkActive => Some(Background::Color(component.divider.into())), + _ => Some(Background::Color(component.pressed.into())), + }; + } + iced_button::Status::Disabled => { + // Card color is not transparent when it isn't clickable + if matches!(class, Button::Card) { + return appearance; + } + appearance.background = appearance.background.map(|background| match background { + Background::Color(color) => Background::Color(Color { + a: color.a * 0.5, + ..color + }), + Background::Gradient(gradient) => { + Background::Gradient(gradient.scale_alpha(0.5)) + } + }); + appearance.text_color = Color { + a: appearance.text_color.a * 0.5, + ..appearance.text_color + }; + } + }; + appearance + } } impl Button { #[allow(clippy::trivially_copy_pass_by_ref)] #[allow(clippy::match_same_arms)] - fn cosmic<'a>(&'a self, theme: &'a Theme) -> &CosmicComponent { + fn cosmic<'a>(&'a self, theme: &'a Theme) -> &'a CosmicComponent { let cosmic = theme.cosmic(); match self { Self::Primary => &cosmic.accent_button, @@ -89,8 +157,8 @@ impl Button { Self::Positive => &cosmic.success_button, Self::Destructive => &cosmic.destructive_button, Self::Text => &cosmic.text_button, - Self::Link => &cosmic.accent_button, - Self::LinkActive => &cosmic.accent_button, + Self::Link => &cosmic.link_button, + Self::LinkActive => &cosmic.link_button, Self::Transparent => &TRANSPARENT_COMPONENT, Self::Deactivated => &theme.current_container().component, Self::Card => &theme.current_container().component, @@ -99,95 +167,6 @@ impl Button { } } -impl iced_button::StyleSheet for Theme { - type Style = Button; - - fn active(&self, style: &Self::Style) -> iced_button::Appearance { - if let Button::Custom { active, .. } = style { - return active(self); - } - - let corner_radii = &self.cosmic().corner_radii; - let component = style.cosmic(self); - iced_button::Appearance { - border_radius: match style { - Button::Link => corner_radii.radius_0.into(), - Button::Card => corner_radii.radius_xs.into(), - _ => corner_radii.radius_xl.into(), - }, - background: match style { - Button::Link | Button::Text => None, - Button::LinkActive => Some(Background::Color(component.divider.into())), - _ => Some(Background::Color(component.base.into())), - }, - text_color: match style { - Button::Link | Button::LinkActive => component.base.into(), - _ => component.on.into(), - }, - ..iced_button::Appearance::default() - } - } - - fn hovered(&self, style: &Self::Style) -> iced_button::Appearance { - if let Button::Custom { hover, .. } = style { - return hover(self); - } - - let active = self.active(style); - let component = style.cosmic(self); - - iced_button::Appearance { - background: match style { - Button::Link => None, - Button::LinkActive => Some(Background::Color(component.divider.into())), - _ => Some(Background::Color(component.hover.into())), - }, - ..active - } - } - - fn focused(&self, style: &Self::Style) -> iced_button::Appearance { - if let Button::Custom { hover, .. } = style { - return hover(self); - } - - let active = self.active(style); - let component = style.cosmic(self); - iced_button::Appearance { - background: match style { - Button::Link => None, - Button::LinkActive => Some(Background::Color(component.divider.into())), - _ => Some(Background::Color(component.hover.into())), - }, - ..active - } - } - - fn disabled(&self, style: &Self::Style) -> iced_button::Appearance { - let active = self.active(style); - - if matches!(style, Button::Card) { - return active; - } - - iced_button::Appearance { - shadow_offset: iced_core::Vector::default(), - background: active.background.map(|background| match background { - Background::Color(color) => Background::Color(Color { - a: color.a * 0.5, - ..color - }), - Background::Gradient(gradient) => Background::Gradient(gradient.mul_alpha(0.5)), - }), - text_color: Color { - a: active.text_color.a * 0.5, - ..active.text_color - }, - ..active - } - } -} - /* * TODO: Checkbox */ @@ -205,153 +184,200 @@ impl Default for Checkbox { } } -impl checkbox::StyleSheet for Theme { - type Style = Checkbox; +impl iced_checkbox::Catalog for Theme { + type Class<'a> = Checkbox; - fn active(&self, style: &Self::Style, is_checked: bool) -> checkbox::Appearance { - let cosmic = self.cosmic(); - - let corners = &cosmic.corner_radii; - match style { - Checkbox::Primary => checkbox::Appearance { - background: Background::Color(if is_checked { - cosmic.accent.base.into() - } else { - cosmic.button.base.into() - }), - icon_color: cosmic.accent.on.into(), - border_radius: corners.radius_xs.into(), - border_width: if is_checked { 0.0 } else { 1.0 }, - border_color: if is_checked { - cosmic.accent.base - } else { - cosmic.button.border - } - .into(), - text_color: None, - }, - Checkbox::Secondary => checkbox::Appearance { - background: Background::Color(if is_checked { - cosmic.background.component.base.into() - } else { - cosmic.background.base.into() - }), - icon_color: cosmic.background.on.into(), - border_radius: corners.radius_xs.into(), - border_width: if is_checked { 0.0 } else { 1.0 }, - border_color: cosmic.button.border.into(), - text_color: None, - }, - Checkbox::Success => checkbox::Appearance { - background: Background::Color(if is_checked { - cosmic.success.base.into() - } else { - cosmic.button.base.into() - }), - icon_color: cosmic.success.on.into(), - border_radius: corners.radius_xs.into(), - border_width: if is_checked { 0.0 } else { 1.0 }, - border_color: if is_checked { - cosmic.success.base - } else { - cosmic.button.border - } - .into(), - text_color: None, - }, - Checkbox::Danger => checkbox::Appearance { - background: Background::Color(if is_checked { - cosmic.destructive.base.into() - } else { - cosmic.button.base.into() - }), - icon_color: cosmic.destructive.on.into(), - border_radius: corners.radius_xs.into(), - border_width: if is_checked { 0.0 } else { 1.0 }, - border_color: if is_checked { - cosmic.destructive.base - } else { - cosmic.button.border - } - .into(), - text_color: None, - }, - } + fn default<'a>() -> Self::Class<'a> { + Checkbox::default() } - fn hovered(&self, style: &Self::Style, is_checked: bool) -> checkbox::Appearance { + #[allow(clippy::too_many_lines)] + fn style( + &self, + class: &Self::Class<'_>, + status: iced_checkbox::Status, + ) -> iced_checkbox::Style { let cosmic = self.cosmic(); + let corners = &cosmic.corner_radii; - match style { - Checkbox::Primary => checkbox::Appearance { - background: Background::Color(if is_checked { - cosmic.accent.base.into() - } else { - cosmic.button.base.into() - }), - icon_color: cosmic.accent.on.into(), - border_radius: corners.radius_xs.into(), - border_width: if is_checked { 0.0 } else { 1.0 }, - border_color: if is_checked { - cosmic.accent.base - } else { - cosmic.button.border + let disabled = matches!(status, iced_checkbox::Status::Disabled { .. }); + match status { + iced_checkbox::Status::Active { is_checked } + | iced_checkbox::Status::Disabled { is_checked } => { + let mut active = match class { + Checkbox::Primary => iced_checkbox::Style { + background: Background::Color(if is_checked { + cosmic.accent.base.into() + } else { + self.current_container().small_widget.into() + }), + icon_color: cosmic.accent.on.into(), + border: Border { + radius: corners.radius_xs.into(), + width: if is_checked { 0.0 } else { 1.0 }, + color: if is_checked { + cosmic.accent.base + } else { + cosmic.palette.neutral_8 + } + .into(), + }, + + text_color: None, + }, + Checkbox::Secondary => iced_checkbox::Style { + background: Background::Color(if is_checked { + cosmic.background.component.base.into() + } else { + self.current_container().small_widget.into() + }), + icon_color: cosmic.background.on.into(), + border: Border { + radius: corners.radius_xs.into(), + width: if is_checked { 0.0 } else { 1.0 }, + color: cosmic.palette.neutral_8.into(), + }, + text_color: None, + }, + Checkbox::Success => iced_checkbox::Style { + background: Background::Color(if is_checked { + cosmic.success.base.into() + } else { + self.current_container().small_widget.into() + }), + icon_color: cosmic.success.on.into(), + border: Border { + radius: corners.radius_xs.into(), + width: if is_checked { 0.0 } else { 1.0 }, + color: if is_checked { + cosmic.success.base + } else { + cosmic.palette.neutral_8 + } + .into(), + }, + text_color: None, + }, + Checkbox::Danger => iced_checkbox::Style { + background: Background::Color(if is_checked { + cosmic.destructive.base.into() + } else { + self.current_container().small_widget.into() + }), + icon_color: cosmic.destructive.on.into(), + border: Border { + radius: corners.radius_xs.into(), + width: if is_checked { 0.0 } else { 1.0 }, + color: if is_checked { + cosmic.destructive.base + } else { + cosmic.palette.neutral_8 + } + .into(), + }, + text_color: None, + }, + }; + if disabled { + match &mut active.background { + Background::Color(color) => { + color.a /= 2.; + } + Background::Gradient(gradient) => { + *gradient = gradient.scale_alpha(0.5); + } + } + if let Some(c) = active.text_color.as_mut() { + c.a /= 2. + }; + active.border.color.a /= 2.; } - .into(), - text_color: None, - }, - Checkbox::Secondary => checkbox::Appearance { - background: Background::Color(if is_checked { - self.current_container().base.into() - } else { - cosmic.button.base.into() - }), - icon_color: self.current_container().on.into(), - border_radius: corners.radius_xs.into(), - border_width: if is_checked { 0.0 } else { 1.0 }, - border_color: if is_checked { - self.current_container().base - } else { - cosmic.button.border + active + } + iced_checkbox::Status::Hovered { is_checked } => { + let cur_container = self.current_container().small_widget; + // TODO: this should probably be done with a custom widget instead, or the theme needs more small widget variables. + let hovered_bg = over(cosmic.palette.neutral_0.with_alpha(0.1), cur_container); + match class { + Checkbox::Primary => iced_checkbox::Style { + background: Background::Color(if is_checked { + cosmic.accent.hover_state_color().into() + } else { + hovered_bg.into() + }), + icon_color: cosmic.accent.on.into(), + border: Border { + radius: corners.radius_xs.into(), + width: if is_checked { 0.0 } else { 1.0 }, + color: if is_checked { + cosmic.accent.base + } else { + cosmic.palette.neutral_8 + } + .into(), + }, + text_color: None, + }, + Checkbox::Secondary => iced_checkbox::Style { + background: Background::Color(if is_checked { + self.current_container().component.hover.into() + } else { + hovered_bg.into() + }), + icon_color: self.current_container().on.into(), + border: Border { + radius: corners.radius_xs.into(), + width: if is_checked { 0.0 } else { 1.0 }, + color: if is_checked { + self.current_container().base + } else { + cosmic.palette.neutral_8 + } + .into(), + }, + text_color: None, + }, + Checkbox::Success => iced_checkbox::Style { + background: Background::Color(if is_checked { + cosmic.success.hover.into() + } else { + hovered_bg.into() + }), + icon_color: cosmic.success.on.into(), + border: Border { + radius: corners.radius_xs.into(), + width: if is_checked { 0.0 } else { 1.0 }, + color: if is_checked { + cosmic.success.base + } else { + cosmic.palette.neutral_8 + } + .into(), + }, + text_color: None, + }, + Checkbox::Danger => iced_checkbox::Style { + background: Background::Color(if is_checked { + cosmic.destructive.hover.into() + } else { + hovered_bg.into() + }), + icon_color: cosmic.destructive.on.into(), + border: Border { + radius: corners.radius_xs.into(), + width: if is_checked { 0.0 } else { 1.0 }, + color: if is_checked { + cosmic.destructive.base + } else { + cosmic.palette.neutral_8 + } + .into(), + }, + text_color: None, + }, } - .into(), - text_color: None, - }, - Checkbox::Success => checkbox::Appearance { - background: Background::Color(if is_checked { - cosmic.success.base.into() - } else { - cosmic.button.base.into() - }), - icon_color: cosmic.success.on.into(), - border_radius: corners.radius_xs.into(), - border_width: if is_checked { 0.0 } else { 1.0 }, - border_color: if is_checked { - cosmic.success.base - } else { - cosmic.button.border - } - .into(), - text_color: None, - }, - Checkbox::Danger => checkbox::Appearance { - background: Background::Color(if is_checked { - cosmic.destructive.base.into() - } else { - cosmic.button.base.into() - }), - icon_color: cosmic.destructive.on.into(), - border_radius: corners.radius_xs.into(), - border_width: if is_checked { 0.0 } else { 1.0 }, - border_color: if is_checked { - cosmic.destructive.base - } else { - cosmic.button.border - } - .into(), - text_color: None, - }, + } } } } @@ -360,12 +386,20 @@ impl checkbox::StyleSheet for Theme { * TODO: Container */ #[derive(Default)] -pub enum Container { +pub enum Container<'a> { + WindowBackground, Background, Card, - Custom(Box container::Appearance>), + ContextDrawer, + Custom(Box iced_container::Style + 'a>), + Dialog, Dropdown, - HeaderBar, + HeaderBar { + focused: bool, + sharp_corners: bool, + transparent: bool, + }, + List, Primary, Secondary, Tooltip, @@ -373,143 +407,265 @@ pub enum Container { Transparent, } -impl Container { - pub fn custom container::Appearance + 'static>(f: F) -> Self { +impl<'a> Container<'a> { + pub fn custom iced_container::Style + 'a>(f: F) -> Self { Self::Custom(Box::new(f)) } + + #[must_use] + pub fn background(theme: &cosmic_theme::Theme) -> iced_container::Style { + iced_container::Style { + icon_color: Some(Color::from(theme.background.on)), + text_color: Some(Color::from(theme.background.on)), + background: Some(iced::Background::Color(theme.background.base.into())), + border: Border { + radius: theme.corner_radii.radius_s.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + } + } + + #[must_use] + pub fn primary(theme: &cosmic_theme::Theme) -> iced_container::Style { + iced_container::Style { + icon_color: Some(Color::from(theme.primary.on)), + text_color: Some(Color::from(theme.primary.on)), + background: Some(iced::Background::Color(theme.primary.base.into())), + border: Border { + radius: theme.corner_radii.radius_s.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + } + } + + #[must_use] + pub fn secondary(theme: &cosmic_theme::Theme) -> iced_container::Style { + iced_container::Style { + icon_color: Some(Color::from(theme.secondary.on)), + text_color: Some(Color::from(theme.secondary.on)), + background: Some(iced::Background::Color(theme.secondary.base.into())), + border: Border { + radius: theme.corner_radii.radius_s.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + } + } } -impl container::StyleSheet for Theme { - type Style = Container; +impl<'a> From> for Container<'a> { + fn from(value: iced_container::StyleFn<'a, Theme>) -> Self { + Self::custom(value) + } +} + +impl iced_container::Catalog for Theme { + type Class<'a> = Container<'a>; + + fn default<'a>() -> Self::Class<'a> { + Container::default() + } + + fn style(&self, class: &Self::Class<'_>) -> iced_container::Style { + let cosmic = self.cosmic(); + + // Ensures visually aligned radii for content and window corners + let window_corner_radius = cosmic.radius_s().map(|x| if x < 4.0 { x } else { x + 4.0 }); + + match class { + Container::Transparent => iced_container::Style::default(), - #[allow(clippy::too_many_lines)] - fn appearance(&self, style: &Self::Style) -> container::Appearance { - match style { - Container::Transparent => container::Appearance::default(), Container::Custom(f) => f(self), - Container::Background => { - let palette = self.cosmic(); - container::Appearance { - icon_color: Some(Color::from(palette.background.on)), - text_color: Some(Color::from(palette.background.on)), - background: Some(iced::Background::Color(palette.background.base.into())), - border_radius: 2.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - } - } - Container::HeaderBar => { - let palette = self.cosmic(); - let mut header_top = palette.background.base; - let header_bottom = palette.background.base; - header_top.alpha = 0.8; + Container::WindowBackground => iced_container::Style { + icon_color: Some(Color::from(cosmic.background.on)), + text_color: Some(Color::from(cosmic.background.on)), + background: Some(iced::Background::Color(cosmic.background.base.into())), + border: Border { + radius: [ + cosmic.corner_radii.radius_0[0], + cosmic.corner_radii.radius_0[1], + window_corner_radius[2], + window_corner_radius[3], + ] + .into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }, - container::Appearance { - icon_color: Some(Color::from(palette.accent.base)), - text_color: Some(Color::from(palette.background.on)), - background: Some(iced::Background::Gradient(iced_core::Gradient::Linear( - Linear::new(Radians(3.0 * PI / 2.0)) - .add_stop(0.0, header_top.into()) - .add_stop(1.0, header_bottom.into()), - ))), - border_radius: BorderRadius::from([ - palette.corner_radii.radius_xs[0], - palette.corner_radii.radius_xs[3], - 0.0, - 0.0, - ]), - border_width: 0.0, - border_color: Color::TRANSPARENT, - } - } - Container::Primary => { - let palette = self.cosmic(); - - container::Appearance { - icon_color: Some(Color::from(palette.primary.on)), - text_color: Some(Color::from(palette.primary.on)), - background: Some(iced::Background::Color(palette.primary.base.into())), - border_radius: 2.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - } - } - Container::Secondary => { - let palette = self.cosmic(); - - container::Appearance { - icon_color: Some(Color::from(palette.secondary.on)), - text_color: Some(Color::from(palette.secondary.on)), - background: Some(iced::Background::Color(palette.secondary.base.into())), - border_radius: 2.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, + Container::List => { + let component = &self.current_container().component; + iced_container::Style { + icon_color: Some(component.on.into()), + text_color: Some(component.on.into()), + background: Some(Background::Color(component.base.into())), + border: iced::Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, } } - Container::Dropdown => { - let theme = self.cosmic(); + Container::HeaderBar { + focused, + sharp_corners, + transparent, + } => { + let (icon_color, text_color) = if *focused { + ( + Color::from(cosmic.accent_text_color()), + Color::from(cosmic.background.on), + ) + } else { + use crate::ext::ColorExt; + let unfocused_color = Color::from(cosmic.background.component.on) + .blend_alpha(cosmic.background.base.into(), 0.5); + (unfocused_color, unfocused_color) + }; - container::Appearance { - icon_color: None, - text_color: None, - background: Some(iced::Background::Color(theme.primary.base.into())), - border_radius: f32::from(theme.space_xxs()).into(), - border_width: 1.0, - border_color: theme.bg_divider().into(), + iced_container::Style { + icon_color: Some(icon_color), + text_color: Some(text_color), + background: if *transparent { + None + } else { + Some(iced::Background::Color(cosmic.background.base.into())) + }, + border: Border { + radius: [ + if *sharp_corners { + cosmic.corner_radii.radius_0[0] + } else { + window_corner_radius[0] + }, + if *sharp_corners { + cosmic.corner_radii.radius_0[1] + } else { + window_corner_radius[1] + }, + cosmic.corner_radii.radius_0[2], + cosmic.corner_radii.radius_0[3], + ] + .into(), + ..Default::default() + }, + snap: true, + shadow: Shadow::default(), } } - Container::Tooltip => { - let theme = self.cosmic(); + Container::ContextDrawer => { + let mut a = Container::primary(cosmic); - container::Appearance { - icon_color: None, - text_color: None, - background: Some(iced::Background::Color(theme.palette.neutral_2.into())), - border_radius: f32::from(theme.space_xl()).into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, + if cosmic.is_high_contrast { + a.border.width = 1.; + a.border.color = cosmic.primary.divider.into(); } + a } + Container::Background => Container::background(cosmic), + + Container::Primary => Container::primary(cosmic), + + Container::Secondary => Container::secondary(cosmic), + + Container::Dropdown => iced_container::Style { + icon_color: None, + text_color: None, + background: Some(iced::Background::Color(cosmic.bg_component_color().into())), + border: Border { + color: cosmic.bg_component_divider().into(), + width: 1.0, + radius: cosmic.corner_radii.radius_s.into(), + }, + shadow: Shadow::default(), + snap: true, + }, + + Container::Tooltip => iced_container::Style { + icon_color: None, + text_color: None, + background: Some(iced::Background::Color(cosmic.palette.neutral_2.into())), + border: Border { + radius: cosmic.corner_radii.radius_l.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }, + Container::Card => { - let palette = self.cosmic(); + let cosmic = self.cosmic(); match self.layer { - cosmic_theme::Layer::Background => container::Appearance { - icon_color: Some(Color::from(palette.background.component.on)), - text_color: Some(Color::from(palette.background.component.on)), + cosmic_theme::Layer::Background => iced_container::Style { + icon_color: Some(Color::from(cosmic.background.component.on)), + text_color: Some(Color::from(cosmic.background.component.on)), background: Some(iced::Background::Color( - palette.background.component.base.into(), + cosmic.background.component.base.into(), )), - border_radius: 8.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, }, - cosmic_theme::Layer::Primary => container::Appearance { - icon_color: Some(Color::from(palette.primary.component.on)), - text_color: Some(Color::from(palette.primary.component.on)), + cosmic_theme::Layer::Primary => iced_container::Style { + icon_color: Some(Color::from(cosmic.primary.component.on)), + text_color: Some(Color::from(cosmic.primary.component.on)), background: Some(iced::Background::Color( - palette.primary.component.base.into(), + cosmic.primary.component.base.into(), )), - border_radius: 8.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, }, - cosmic_theme::Layer::Secondary => container::Appearance { - icon_color: Some(Color::from(palette.secondary.component.on)), - text_color: Some(Color::from(palette.secondary.component.on)), + cosmic_theme::Layer::Secondary => iced_container::Style { + icon_color: Some(Color::from(cosmic.secondary.component.on)), + text_color: Some(Color::from(cosmic.secondary.component.on)), background: Some(iced::Background::Color( - palette.secondary.component.base.into(), + cosmic.secondary.component.base.into(), )), - border_radius: 8.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, }, } } + + Container::Dialog => iced_container::Style { + icon_color: Some(Color::from(cosmic.primary.on)), + text_color: Some(Color::from(cosmic.primary.on)), + background: Some(iced::Background::Color(cosmic.primary.base.into())), + border: Border { + color: cosmic.primary.divider.into(), + width: 1.0, + radius: cosmic.corner_radii.radius_m.into(), + }, + shadow: Shadow { + color: cosmic.shade.into(), + offset: Vector::new(0.0, 4.0), + blur_radius: 16.0, + }, + snap: true, + }, } } } @@ -519,127 +675,176 @@ pub enum Slider { #[default] Standard, Custom { - active: Rc slider::Appearance>, - hovered: Rc slider::Appearance>, - dragging: Rc slider::Appearance>, + active: Rc slider::Style>, + hovered: Rc slider::Style>, + dragging: Rc slider::Style>, }, } /* * Slider */ -impl slider::StyleSheet for Theme { - type Style = Slider; +impl slider::Catalog for Theme { + type Class<'a> = Slider; - fn active(&self, style: &Self::Style) -> slider::Appearance { - match style { + fn default<'a>() -> Self::Class<'a> { + Slider::default() + } + + fn style(&self, class: &Self::Class<'_>, status: slider::Status) -> slider::Style { + let cosmic: &cosmic_theme::Theme = self.cosmic(); + let hc = self.theme_type.is_high_contrast(); + let is_dark = self.theme_type.is_dark(); + + let mut appearance = match class { Slider::Standard => //TODO: no way to set rail thickness { - let cosmic: &cosmic_theme::Theme> = - self.cosmic(); - - slider::Appearance { + let (active_track, inactive_track) = if hc { + ( + cosmic.accent_text_color(), + if is_dark { + cosmic.palette.neutral_5 + } else { + cosmic.palette.neutral_3 + }, + ) + } else { + (cosmic.accent.base, cosmic.palette.neutral_6) + }; + slider::Style { rail: Rail { - colors: slider::RailBackground::Pair( - cosmic.accent.base.into(), - //TODO: no way to set color before/after slider - Color::TRANSPARENT, + backgrounds: ( + Background::Color(active_track.into()), + Background::Color(inactive_track.into()), ), + border: Border { + radius: cosmic.corner_radii.radius_xs.into(), + color: if hc && !is_dark { + self.current_container().component.border.into() + } else { + Color::TRANSPARENT + }, + width: if hc && !is_dark { 1. } else { 0. }, + }, width: 4.0, - border_radius: 2.0.into(), }, handle: slider::Handle { - shape: slider::HandleShape::Circle { radius: 10.0 }, - color: cosmic.accent.base.into(), + shape: slider::HandleShape::Rectangle { + height: 20, + width: 20, + border_radius: cosmic.corner_radii.radius_m.into(), + }, border_color: Color::TRANSPARENT, border_width: 0.0, + background: Background::Color(cosmic.accent.base.into()), + }, + + breakpoint: slider::Breakpoint { + color: cosmic.on_bg_color().into(), }, } } Slider::Custom { active, .. } => active(self), - } - } - - fn hovered(&self, style: &Self::Style) -> slider::Appearance { - match style { - Slider::Standard => { - let mut style = self.active(style); - style.handle.shape = slider::HandleShape::Circle { radius: 16.0 }; - style.handle.border_width = 6.0; - let mut border_color = self.cosmic().palette.neutral_10; - border_color.alpha = 0.1; - style.handle.border_color = border_color.into(); - style - } - Slider::Custom { hovered, .. } => hovered(self), - } - } - - fn dragging(&self, style: &Self::Style) -> slider::Appearance { - match style { - Slider::Standard => { - let mut style = self.hovered(style); - let mut border_color = self.cosmic().palette.neutral_10; - border_color.alpha = 0.2; - style.handle.border_color = border_color.into(); - - style - } - Slider::Custom { dragging, .. } => dragging(self), + }; + match status { + slider::Status::Active => appearance, + slider::Status::Hovered => match class { + Slider::Standard => { + appearance.handle.shape = slider::HandleShape::Rectangle { + height: 26, + width: 26, + border_radius: cosmic.corner_radii.radius_m.into(), + }; + appearance.handle.border_width = 3.0; + appearance.handle.border_color = + self.cosmic().palette.neutral_10.with_alpha(0.1).into(); + appearance + } + Slider::Custom { hovered, .. } => hovered(self), + }, + slider::Status::Dragged => match class { + Slider::Standard => { + let mut style = { + appearance.handle.shape = slider::HandleShape::Rectangle { + height: 26, + width: 26, + border_radius: cosmic.corner_radii.radius_m.into(), + }; + appearance.handle.border_width = 3.0; + appearance.handle.border_color = + self.cosmic().palette.neutral_10.with_alpha(0.1).into(); + appearance + }; + style.handle.border_color = + self.cosmic().palette.neutral_10.with_alpha(0.2).into(); + style + } + Slider::Custom { dragging, .. } => dragging(self), + }, } } } -/* - * TODO: Menu - */ -impl menu::StyleSheet for Theme { - type Style = (); +impl menu::Catalog for Theme { + type Class<'a> = (); - fn appearance(&self, _style: &Self::Style) -> menu::Appearance { + fn default<'a>() -> ::Class<'a> {} + + fn style(&self, class: &::Class<'_>) -> menu::Style { let cosmic = self.cosmic(); - menu::Appearance { + menu::Style { text_color: cosmic.on_bg_color().into(), background: Background::Color(cosmic.background.base.into()), - border_width: 0.0, - border_radius: 16.0.into(), - border_color: Color::TRANSPARENT, - selected_text_color: cosmic.accent.base.into(), + border: Border { + radius: cosmic.corner_radii.radius_m.into(), + ..Default::default() + }, + selected_text_color: cosmic.accent_text_color().into(), selected_background: Background::Color(cosmic.background.component.hover.into()), + shadow: Default::default(), } } } -/* - * TODO: Pick List - */ -impl pick_list::StyleSheet for Theme { - type Style = (); +impl pick_list::Catalog for Theme { + type Class<'a> = (); - fn active(&self, _style: &()) -> pick_list::Appearance { + fn default<'a>() -> ::Class<'a> {} + + fn style( + &self, + class: &::Class<'_>, + status: pick_list::Status, + ) -> pick_list::Style { let cosmic = &self.cosmic(); - - pick_list::Appearance { + let hc = cosmic.is_high_contrast; + let appearance = pick_list::Style { text_color: cosmic.on_bg_color().into(), background: Color::TRANSPARENT.into(), placeholder_color: cosmic.on_bg_color().into(), - border_radius: 24.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, + border: Border { + radius: cosmic.corner_radii.radius_m.into(), + width: if hc { 1. } else { 0. }, + color: if hc { + self.current_container().component.border.into() + } else { + Color::TRANSPARENT + }, + }, // icon_size: 0.7, // TODO: how to replace handle_color: cosmic.on_bg_color().into(), - } - } + }; - fn hovered(&self, style: &()) -> pick_list::Appearance { - let cosmic = &self.cosmic(); - - pick_list::Appearance { - background: Background::Color(cosmic.background.base.into()), - ..self.active(style) + match status { + pick_list::Status::Active => appearance, + pick_list::Status::Hovered => pick_list::Style { + background: Background::Color(cosmic.background.base.into()), + ..appearance + }, + pick_list::Status::Opened { is_hovered: _ } => appearance, } } } @@ -647,52 +852,52 @@ impl pick_list::StyleSheet for Theme { /* * TODO: Radio */ -impl radio::StyleSheet for Theme { - type Style = (); +impl radio::Catalog for Theme { + type Class<'a> = (); - fn active(&self, _style: &Self::Style, is_selected: bool) -> radio::Appearance { + fn default<'a>() -> Self::Class<'a> {} + + fn style(&self, class: &Self::Class<'_>, status: radio::Status) -> radio::Style { + let cur_container = self.current_container(); let theme = self.cosmic(); - radio::Appearance { - background: if is_selected { - Color::from(theme.accent.base).into() - } else { - // TODO: this seems to be defined weirdly in FIGMA - Color::from(theme.background.base).into() + match status { + radio::Status::Active { is_selected } => radio::Style { + background: if is_selected { + Color::from(theme.accent.base).into() + } else { + // TODO: this seems to be defined weirdly in FIGMA + Color::from(cur_container.small_widget).into() + }, + dot_color: theme.accent.on.into(), + border_width: 1.0, + border_color: if is_selected { + Color::from(theme.accent.base) + } else { + Color::from(theme.palette.neutral_8) + }, + text_color: None, }, - dot_color: theme.accent.on.into(), - border_width: 1.0, - border_color: if is_selected { - Color::from(theme.accent.base) - } else { - // TODO: this seems to be defined weirdly in FIGMA - Color::from(theme.palette.neutral_7) - }, - text_color: None, - } - } - - fn hovered(&self, _style: &Self::Style, is_selected: bool) -> radio::Appearance { - let theme = self.cosmic(); - let mut neutral_10 = theme.palette.neutral_10; - neutral_10.alpha = 0.1; - - radio::Appearance { - background: if is_selected { - Color::from(theme.accent.base).into() - } else { - // TODO: this seems to be defined weirdly in FIGMA - Color::from(neutral_10).into() - }, - dot_color: theme.accent.on.into(), - border_width: 1.0, - border_color: if is_selected { - Color::from(theme.accent.base) - } else { - // TODO: this seems to be defined weirdly in FIGMA - Color::from(theme.palette.neutral_7) - }, - text_color: None, + radio::Status::Hovered { is_selected } => { + let bg = if is_selected { + theme.accent.base + } else { + self.current_container().small_widget + }; + // TODO: this should probably be done with a custom widget instead, or the theme needs more small widget variables. + let hovered_bg = Color::from(over(theme.palette.neutral_0.with_alpha(0.1), bg)); + radio::Style { + background: hovered_bg.into(), + dot_color: theme.accent.on.into(), + border_width: 1.0, + border_color: if is_selected { + Color::from(theme.accent.base) + } else { + Color::from(theme.palette.neutral_8) + }, + text_color: None, + } + } } } } @@ -700,37 +905,64 @@ impl radio::StyleSheet for Theme { /* * Toggler */ -impl toggler::StyleSheet for Theme { - type Style = (); +impl toggler::Catalog for Theme { + type Class<'a> = (); - fn active(&self, _style: &Self::Style, is_active: bool) -> toggler::Appearance { - let theme = self.cosmic(); - toggler::Appearance { - background: if is_active { - theme.accent.base.into() - } else { - theme.palette.neutral_5.into() - }, - background_border: None, - foreground: theme.palette.neutral_2.into(), - foreground_border: None, - } - } + fn default<'a>() -> Self::Class<'a> {} - fn hovered(&self, style: &Self::Style, is_active: bool) -> toggler::Appearance { + fn style(&self, class: &Self::Class<'_>, status: toggler::Status) -> toggler::Style { let cosmic = self.cosmic(); - //TODO: grab colors from palette - let mut neutral_10 = cosmic.palette.neutral_10; - neutral_10.alpha = 0.1; + const HANDLE_MARGIN: f32 = 2.0; + let neutral_10 = cosmic.palette.neutral_10.with_alpha(0.1); - toggler::Appearance { - background: if is_active { - over(neutral_10, cosmic.accent_color()) + let mut active = toggler::Style { + background: if matches!(status, toggler::Status::Active { is_toggled: true }) { + cosmic.accent.base.into() + } else if cosmic.is_dark { + cosmic.palette.neutral_6.into() } else { - over(neutral_10, cosmic.palette.neutral_5) + cosmic.palette.neutral_5.into() + }, + foreground: cosmic.palette.neutral_2.into(), + border_radius: cosmic.radius_xl().into(), + handle_radius: cosmic + .radius_xl() + .map(|x| (x - HANDLE_MARGIN).max(0.0)) + .into(), + handle_margin: HANDLE_MARGIN, + background_border_width: 0.0, + background_border_color: Color::TRANSPARENT, + foreground_border_width: 0.0, + foreground_border_color: Color::TRANSPARENT, + text_color: None, + padding_ratio: 0.0, + }; + match status { + toggler::Status::Active { is_toggled } => active, + toggler::Status::Hovered { is_toggled } => { + let is_active = matches!(status, toggler::Status::Hovered { is_toggled: true }); + toggler::Style { + background: if is_active { + over(neutral_10, cosmic.accent_color()) + } else { + over( + neutral_10, + if cosmic.is_dark { + cosmic.palette.neutral_6 + } else { + cosmic.palette.neutral_5 + }, + ) + } + .into(), + ..active + } + } + toggler::Status::Disabled { is_toggled } => { + active.background = active.background.scale_alpha(0.5); + active.foreground = active.foreground.scale_alpha(0.5); + active } - .into(), - ..self.active(style, is_active) } } } @@ -738,34 +970,31 @@ impl toggler::StyleSheet for Theme { /* * TODO: Pane Grid */ -impl pane_grid::StyleSheet for Theme { - type Style = (); +impl pane_grid::Catalog for Theme { + type Class<'a> = (); - fn picked_split(&self, _style: &Self::Style) -> Option { + fn default<'a>() -> ::Class<'a> {} + + fn style(&self, class: &::Class<'_>) -> pane_grid::Style { let theme = self.cosmic(); - Some(pane_grid::Line { - color: theme.accent.base.into(), - width: 2.0, - }) - } - - fn hovered_split(&self, _style: &Self::Style) -> Option { - let theme = self.cosmic(); - - Some(pane_grid::Line { - color: theme.accent.hover.into(), - width: 2.0, - }) - } - - fn hovered_region(&self, _style: &Self::Style) -> pane_grid::Appearance { - let theme = self.cosmic(); - pane_grid::Appearance { - background: Background::Color(theme.bg_color().into()), - border_width: 2.0, - border_color: theme.bg_divider().into(), - border_radius: 0.0.into(), + pane_grid::Style { + hovered_region: Highlight { + background: Background::Color(theme.bg_color().into()), + border: Border { + radius: theme.corner_radii.radius_0.into(), + width: 2.0, + color: theme.bg_divider().into(), + }, + }, + picked_split: pane_grid::Line { + color: theme.accent.base.into(), + width: 2.0, + }, + hovered_split: pane_grid::Line { + color: theme.accent.hover.into(), + width: 2.0, + }, } } } @@ -779,36 +1008,65 @@ pub enum ProgressBar { Primary, Success, Danger, - Custom(Box progress_bar::Appearance>), + Custom(Box progress_bar::Style>), } impl ProgressBar { - pub fn custom progress_bar::Appearance + 'static>(f: F) -> Self { + pub fn custom progress_bar::Style + 'static>(f: F) -> Self { Self::Custom(Box::new(f)) } } -impl progress_bar::StyleSheet for Theme { - type Style = ProgressBar; +impl progress_bar::Catalog for Theme { + type Class<'a> = ProgressBar; - fn appearance(&self, style: &Self::Style) -> progress_bar::Appearance { + fn default<'a>() -> Self::Class<'a> { + ProgressBar::default() + } + + fn style(&self, class: &Self::Class<'_>) -> progress_bar::Style { let theme = self.cosmic(); - match style { - ProgressBar::Primary => progress_bar::Appearance { - background: Color::from(theme.background.divider).into(), - bar: Color::from(theme.accent.base).into(), - border_radius: 2.0.into(), + let (active_track, inactive_track) = if theme.is_high_contrast { + ( + theme.accent_text_color(), + if theme.is_dark { + theme.palette.neutral_6 + } else { + theme.palette.neutral_4 + }, + ) + } else { + (theme.accent.base, theme.background.divider) + }; + let border = Border { + radius: theme.corner_radii.radius_xl.into(), + color: if theme.is_high_contrast && !theme.is_dark { + self.current_container().component.border.into() + } else { + Color::TRANSPARENT }, - ProgressBar::Success => progress_bar::Appearance { - background: Color::from(theme.background.divider).into(), + width: if theme.is_high_contrast && !theme.is_dark { + 1. + } else { + 0. + }, + }; + match class { + ProgressBar::Primary => progress_bar::Style { + background: Color::from(inactive_track).into(), + bar: Color::from(active_track).into(), + border, + }, + ProgressBar::Success => progress_bar::Style { + background: Color::from(inactive_track).into(), bar: Color::from(theme.success.base).into(), - border_radius: 2.0.into(), + border, }, - ProgressBar::Danger => progress_bar::Appearance { - background: Color::from(theme.background.divider).into(), + ProgressBar::Danger => progress_bar::Style { + background: Color::from(inactive_track).into(), bar: Color::from(theme.destructive.base).into(), - border_radius: 2.0.into(), + border, }, ProgressBar::Custom(f) => f(self), } @@ -824,86 +1082,201 @@ pub enum Rule { Default, LightDivider, HeavyDivider, - Custom(Box rule::Appearance>), + Custom(Box rule::Style>), } impl Rule { - pub fn custom rule::Appearance + 'static>(f: F) -> Self { + pub fn custom rule::Style + 'static>(f: F) -> Self { Self::Custom(Box::new(f)) } } -impl rule::StyleSheet for Theme { - type Style = Rule; +impl rule::Catalog for Theme { + type Class<'a> = Rule; - fn appearance(&self, style: &Self::Style) -> rule::Appearance { - match style { - Rule::Default => rule::Appearance { + fn default<'a>() -> Self::Class<'a> { + Rule::default() + } + + fn style(&self, class: &Self::Class<'_>) -> rule::Style { + match class { + Rule::Default => rule::Style { color: self.current_container().divider.into(), - width: 1, radius: 0.0.into(), fill_mode: rule::FillMode::Full, + snap: true, }, - Rule::LightDivider => rule::Appearance { + Rule::LightDivider => rule::Style { color: self.current_container().divider.into(), - width: 1, radius: 0.0.into(), - fill_mode: rule::FillMode::Padded(10), + fill_mode: rule::FillMode::Padded(8), + snap: true, }, - Rule::HeavyDivider => rule::Appearance { + Rule::HeavyDivider => rule::Style { color: self.current_container().divider.into(), - width: 4, - radius: 4.0.into(), + radius: 2.0.into(), fill_mode: rule::FillMode::Full, + snap: true, }, Rule::Custom(f) => f(self), } } } +#[derive(Default, Clone, Copy)] +pub enum Scrollable { + #[default] + Permanent, + Minimal, +} + /* * TODO: Scrollable */ -impl scrollable::StyleSheet for Theme { - type Style = (); +impl scrollable::Catalog for Theme { + type Class<'a> = Scrollable; - fn active(&self, _style: &Self::Style) -> scrollable::Scrollbar { - scrollable::Scrollbar { - background: Some(Background::Color( - self.current_container().component.base.into(), - )), - border_radius: 4.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - scroller: scrollable::Scroller { - color: self.current_container().component.divider.into(), - border_radius: 4.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - } + fn default<'a>() -> Self::Class<'a> { + Scrollable::default() } - fn hovered( - &self, - _style: &Self::Style, - _is_mouse_over_scrollbar: bool, - ) -> scrollable::Scrollbar { - let theme = self.cosmic(); + fn style(&self, class: &Self::Class<'_>, status: scrollable::Status) -> scrollable::Style { + match status { + scrollable::Status::Active { + is_horizontal_scrollbar_disabled, + is_vertical_scrollbar_disabled, + } => { + let cosmic = self.cosmic(); + let neutral_5 = cosmic.palette.neutral_5.with_alpha(0.7); + let neutral_6 = cosmic.palette.neutral_6.with_alpha(0.7); + let mut a = scrollable::Style { + container: iced_container::transparent(self), + vertical_rail: scrollable::Rail { + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + background: None, + scroller: scrollable::Scroller { + background: if cosmic.is_dark { + neutral_6.into() + } else { + neutral_5.into() + }, + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + }, + }, + horizontal_rail: scrollable::Rail { + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + background: None, + scroller: scrollable::Scroller { + background: if cosmic.is_dark { + neutral_6.into() + } else { + neutral_5.into() + }, + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + }, + }, + gap: None, + // TODO: what is auto scroll? + auto_scroll: AutoScroll { + background: Color::TRANSPARENT.into(), + border: Border::default(), + shadow: Shadow::default(), + icon: Color::TRANSPARENT.into(), + }, + }; + let small_widget_container = self.current_container().small_widget.with_alpha(0.7); - scrollable::Scrollbar { - background: Some(Background::Color( - self.current_container().component.hover.into(), - )), - border_radius: 4.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - scroller: scrollable::Scroller { - color: theme.accent.base.into(), - border_radius: 4.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, + if matches!(class, Scrollable::Permanent) { + a.horizontal_rail.background = + Some(Background::Color(small_widget_container.into())); + a.vertical_rail.background = + Some(Background::Color(small_widget_container.into())); + } + + a + } + // TODO handle vertical / horizontal + scrollable::Status::Hovered { .. } | scrollable::Status::Dragged { .. } => { + let cosmic = self.cosmic(); + let neutral_5 = cosmic.palette.neutral_5.with_alpha(0.7); + let neutral_6 = cosmic.palette.neutral_6.with_alpha(0.7); + + // if is_mouse_over_scrollbar { + // let hover_overlay = cosmic.palette.neutral_0.with_alpha(0.2); + // neutral_5 = over(hover_overlay, neutral_5); + // } + let mut a: scrollable::Style = scrollable::Style { + container: iced_container::Style::default(), + vertical_rail: scrollable::Rail { + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + background: None, + scroller: scrollable::Scroller { + background: if cosmic.is_dark { + neutral_6.into() + } else { + neutral_5.into() + }, + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + }, + }, + horizontal_rail: scrollable::Rail { + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + background: None, + scroller: scrollable::Scroller { + background: if cosmic.is_dark { + neutral_6.into() + } else { + neutral_5.into() + }, + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + ..Default::default() + }, + }, + }, + gap: None, + // TODO: what is auto scroll? + auto_scroll: AutoScroll { + background: Color::TRANSPARENT.into(), + border: Border::default(), + shadow: Shadow::default(), + icon: Color::TRANSPARENT.into(), + }, + }; + + if matches!(class, Scrollable::Permanent) { + let small_widget_container = + self.current_container().small_widget.with_alpha(0.7); + + a.horizontal_rail.background = + Some(Background::Color(small_widget_container.into())); + a.vertical_rail.background = + Some(Background::Color(small_widget_container.into())); + } + + a + } } } } @@ -911,25 +1284,29 @@ impl scrollable::StyleSheet for Theme { #[derive(Clone, Default)] pub enum Svg { /// Apply a custom appearance filter - Custom(Rc svg::Appearance>), + Custom(Rc svg::Style>), /// No filtering is applied #[default] Default, } impl Svg { - pub fn custom svg::Appearance + 'static>(f: F) -> Self { + pub fn custom svg::Style + 'static>(f: F) -> Self { Self::Custom(Rc::new(f)) } } -impl svg::StyleSheet for Theme { - type Style = Svg; +impl svg::Catalog for Theme { + type Class<'a> = Svg; - fn appearance(&self, style: &Self::Style) -> svg::Appearance { + fn default<'a>() -> Self::Class<'a> { + Svg::default() + } + + fn style(&self, class: &Self::Class<'_>, status: svg::Status) -> svg::Style { #[allow(clippy::match_same_arms)] - match style { - Svg::Default => svg::Appearance::default(), + match class { + Svg::Default => svg::Style::default(), Svg::Custom(appearance) => appearance(self), } } @@ -945,7 +1322,7 @@ pub enum Text { Default, Color(Color), // TODO: Can't use dyn Fn since this must be copy - Custom(fn(&Theme) -> iced_widget::text::Appearance), + Custom(fn(&Theme) -> iced_widget::text::Style), } impl From for Text { @@ -954,16 +1331,20 @@ impl From for Text { } } -impl iced_widget::text::StyleSheet for Theme { - type Style = Text; +impl iced_widget::text::Catalog for Theme { + type Class<'a> = Text; - fn appearance(&self, style: Self::Style) -> iced_widget::text::Appearance { - match style { - Text::Accent => iced_widget::text::Appearance { - color: Some(self.cosmic().accent.base.into()), + fn default<'a>() -> Self::Class<'a> { + Text::default() + } + + fn style(&self, class: &Self::Class<'_>) -> iced_widget::text::Style { + match class { + Text::Accent => iced_widget::text::Style { + color: Some(self.cosmic().accent_text_color().into()), }, - Text::Default => iced_widget::text::Appearance { color: None }, - Text::Color(c) => iced_widget::text::Appearance { color: Some(c) }, + Text::Default => iced_widget::text::Style { color: None }, + Text::Color(c) => iced_widget::text::Style { color: Some(*c) }, Text::Custom(f) => f(self), } } @@ -979,125 +1360,278 @@ pub enum TextInput { /* * TODO: Text Input */ -impl text_input::StyleSheet for Theme { - type Style = TextInput; +impl text_input::Catalog for Theme { + type Class<'a> = TextInput; - fn active(&self, style: &Self::Style) -> text_input::Appearance { + fn default<'a>() -> Self::Class<'a> { + TextInput::default() + } + + fn style(&self, class: &Self::Class<'_>, status: text_input::Status) -> text_input::Style { let palette = self.cosmic(); - let mut bg = palette.palette.neutral_7; - bg.alpha = 0.25; - match style { - TextInput::Default => text_input::Appearance { + let bg = self.current_container().small_widget.with_alpha(0.25); + + let neutral_9 = palette.palette.neutral_9; + let value = neutral_9.into(); + let placeholder = neutral_9.with_alpha(0.7).into(); + let selection = palette.accent.base.into(); + + let mut appearance = match class { + TextInput::Default => text_input::Style { background: Color::from(bg).into(), - border_radius: 8.0.into(), - border_width: 1.0, - border_color: self.current_container().component.divider.into(), - icon_color: self.current_container().on.into(), + border: Border { + radius: palette.corner_radii.radius_s.into(), + width: 1.0, + color: self.current_container().component.divider.into(), + }, + icon: self.current_container().on.into(), + placeholder, + value, + selection, }, - TextInput::Search => text_input::Appearance { + TextInput::Search => text_input::Style { background: Color::from(bg).into(), - border_radius: 24.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.into(), + border: Border { + radius: palette.corner_radii.radius_m.into(), + ..Default::default() + }, + icon: self.current_container().on.into(), + placeholder, + value, + selection, }, + }; + + match status { + text_input::Status::Active => appearance, + text_input::Status::Hovered => { + let bg = self.current_container().small_widget.with_alpha(0.25); + + match class { + TextInput::Default => text_input::Style { + background: Color::from(bg).into(), + border: Border { + radius: palette.corner_radii.radius_s.into(), + width: 1.0, + color: self.current_container().on.into(), + }, + icon: self.current_container().on.into(), + placeholder, + value, + selection, + }, + TextInput::Search => text_input::Style { + background: Color::from(bg).into(), + border: Border { + radius: palette.corner_radii.radius_m.into(), + ..Default::default() + }, + icon: self.current_container().on.into(), + placeholder, + value, + selection, + }, + } + } + text_input::Status::Focused { is_hovered } => { + let bg = self.current_container().small_widget.with_alpha(0.25); + + match class { + TextInput::Default => text_input::Style { + background: Color::from(bg).into(), + border: Border { + radius: palette.corner_radii.radius_s.into(), + width: 1.0, + color: palette.accent.base.into(), + }, + icon: self.current_container().on.into(), + placeholder, + value, + selection, + }, + TextInput::Search => text_input::Style { + background: Color::from(bg).into(), + border: Border { + radius: palette.corner_radii.radius_m.into(), + ..Default::default() + }, + icon: self.current_container().on.into(), + placeholder, + value, + selection, + }, + } + } + text_input::Status::Disabled => { + appearance.background = match appearance.background { + Background::Color(color) => Background::Color(Color { + a: color.a * 0.5, + ..color + }), + Background::Gradient(gradient) => { + Background::Gradient(gradient.scale_alpha(0.5)) + } + }; + appearance.border.color.a /= 2.; + appearance.icon.a /= 2.; + appearance.placeholder.a /= 2.; + appearance.value.a /= 2.; + appearance + } } } - - fn hovered(&self, style: &Self::Style) -> text_input::Appearance { - let palette = self.cosmic(); - let mut bg = palette.palette.neutral_7; - bg.alpha = 0.25; - - match style { - TextInput::Default => text_input::Appearance { - background: Color::from(bg).into(), - border_radius: 8.0.into(), - border_width: 1.0, - border_color: palette.accent.base.into(), - icon_color: self.current_container().on.into(), - }, - TextInput::Search => text_input::Appearance { - background: Color::from(bg).into(), - border_radius: 24.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.into(), - }, - } - } - - fn focused(&self, style: &Self::Style) -> text_input::Appearance { - let palette = self.cosmic(); - let mut bg = palette.palette.neutral_7; - bg.alpha = 0.25; - - match style { - TextInput::Default => text_input::Appearance { - background: Color::from(bg).into(), - border_radius: 8.0.into(), - border_width: 1.0, - border_color: palette.accent.base.into(), - icon_color: self.current_container().on.into(), - }, - TextInput::Search => text_input::Appearance { - background: Color::from(bg).into(), - border_radius: 24.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - icon_color: self.current_container().on.into(), - }, - } - } - - fn placeholder_color(&self, _style: &Self::Style) -> Color { - let palette = self.cosmic(); - let mut neutral_9 = palette.palette.neutral_9; - neutral_9.alpha = 0.7; - neutral_9.into() - } - - fn value_color(&self, _style: &Self::Style) -> Color { - let palette = self.cosmic(); - - palette.palette.neutral_9.into() - } - - fn selection_color(&self, _style: &Self::Style) -> Color { - let palette = self.cosmic(); - - palette.accent.base.into() - } - - fn disabled_color(&self, _style: &Self::Style) -> Color { - let palette = self.cosmic(); - let mut neutral_9 = palette.palette.neutral_9; - neutral_9.alpha = 0.5; - neutral_9.into() - } - - fn disabled(&self, style: &Self::Style) -> text_input::Appearance { - self.active(style) - } } -impl crate::widget::card::style::StyleSheet for Theme { - fn default(&self) -> crate::widget::card::style::Appearance { +#[derive(Default)] +pub enum TextEditor<'a> { + #[default] + Default, + Custom(text_editor::StyleFn<'a, Theme>), +} + +impl iced_widget::text_editor::Catalog for Theme { + type Class<'a> = TextEditor<'a>; + + fn default<'a>() -> Self::Class<'a> { + TextEditor::default() + } + + fn style( + &self, + class: &Self::Class<'_>, + status: iced_widget::text_editor::Status, + ) -> iced_widget::text_editor::Style { + if let TextEditor::Custom(style) = class { + return style(self, status); + } + let cosmic = self.cosmic(); - match self.layer { - cosmic_theme::Layer::Background => crate::widget::card::style::Appearance { - card_1: Background::Color(cosmic.background.component.hover.into()), - card_2: Background::Color(cosmic.background.component.pressed.into()), - }, - cosmic_theme::Layer::Primary => crate::widget::card::style::Appearance { - card_1: Background::Color(cosmic.primary.component.hover.into()), - card_2: Background::Color(cosmic.primary.component.pressed.into()), - }, - cosmic_theme::Layer::Secondary => crate::widget::card::style::Appearance { - card_1: Background::Color(cosmic.secondary.component.hover.into()), - card_2: Background::Color(cosmic.secondary.component.pressed.into()), + let selection = cosmic.accent.base.into(); + let value = cosmic.palette.neutral_9.into(); + let placeholder = cosmic.palette.neutral_9.with_alpha(0.7).into(); + let icon: Color = cosmic.background.on.into(); + // TODO do we need to add icon color back? + + match status { + iced_widget::text_editor::Status::Active + | iced_widget::text_editor::Status::Hovered + | iced_widget::text_editor::Status::Disabled => iced_widget::text_editor::Style { + background: iced::Color::from(cosmic.bg_color()).into(), + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + width: f32::from(cosmic.space_xxxs()), + color: iced::Color::from(cosmic.bg_divider()), + }, + placeholder, + value, + selection, }, + iced_widget::text_editor::Status::Focused { is_hovered } => { + iced_widget::text_editor::Style { + background: iced::Color::from(cosmic.bg_color()).into(), + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + width: f32::from(cosmic.space_xxxs()), + color: iced::Color::from(cosmic.accent.base), + }, + placeholder, + value, + selection, + } + } + } + } +} + +#[cfg(feature = "markdown")] +impl iced_widget::markdown::Catalog for Theme { + fn code_block<'a>() -> ::Class<'a> { + Container::custom(|_| iced_container::Style { + background: Some(iced::color!(0x111111).into()), + text_color: Some(Color::WHITE), + border: iced::border::rounded(2), + ..iced_container::Style::default() + }) + } +} + +impl iced_widget::table::Catalog for Theme { + type Class<'a> = iced_widget::table::StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(|theme| iced_widget::table::Style { + separator_x: theme.current_container().divider.into(), + separator_y: theme.current_container().divider.into(), + }) + } + + fn style(&self, class: &Self::Class<'_>) -> iced_widget::table::Style { + class(self) + } +} + +#[cfg(feature = "qr_code")] +impl iced_widget::qr_code::Catalog for Theme { + type Class<'a> = iced_widget::qr_code::StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(|_theme| iced_widget::qr_code::Style { + cell: Color::BLACK, + background: Color::WHITE, + }) + } + + fn style(&self, class: &Self::Class<'_>) -> iced_widget::qr_code::Style { + class(self) + } +} + +impl combo_box::Catalog for Theme {} + +impl Base for Theme { + fn default(preference: iced::theme::Mode) -> Self { + match preference { + iced::theme::Mode::Light => Theme::light(), + iced::theme::Mode::Dark | iced::theme::Mode::None => Theme::dark(), + } + } + + fn mode(&self) -> iced::theme::Mode { + if self.theme_type.is_dark() { + iced::theme::Mode::Dark + } else { + iced::theme::Mode::Light + } + } + + fn base(&self) -> iced::theme::Style { + iced::theme::Style { + background_color: self.cosmic().bg_color().into(), + text_color: self.cosmic().on_bg_color().into(), + icon_color: self.cosmic().on_bg_color().into(), + } + } + + fn palette(&self) -> Option { + Some(iced::theme::Palette { + primary: self.cosmic().accent.base.into(), + success: self.cosmic().success.base.into(), + warning: self.cosmic().warning.base.into(), + danger: self.cosmic().destructive.base.into(), + background: iced::Color::from(self.cosmic().bg_color()), + text: iced::Color::from(self.cosmic().on_bg_color()), + }) + } + + fn name(&self) -> &str { + match &self.theme_type { + crate::theme::ThemeType::Dark => "Cosmic Dark Theme", + crate::theme::ThemeType::Light => "Cosmic Light Theme", + crate::theme::ThemeType::HighContrastDark => "Cosmic High Contrast Dark Theme", + crate::theme::ThemeType::HighContrastLight => "Cosmic High Contrast Light Theme", + crate::theme::ThemeType::Custom(theme) => "Custom Cosmic Theme", + crate::theme::ThemeType::System { prefer_dark, theme } => &theme.name, } } } diff --git a/src/theme/style/menu_bar.rs b/src/theme/style/menu_bar.rs index 1b17c527..ed0e657a 100644 --- a/src/theme/style/menu_bar.rs +++ b/src/theme/style/menu_bar.rs @@ -1,6 +1,8 @@ // From iced_aw, license MIT //! Change the appearance of menu bars and their menus. +use std::sync::Arc; + use crate::Theme; use iced_widget::core::Color; @@ -19,7 +21,7 @@ pub struct Appearance { pub border_color: Color, /// The expand value of the menus' background pub background_expand: [u16; 4], - /// The highlighted path [`Color`] of the the menu bar and its menus. + // /// The highlighted path [`Color`] of the the menu bar and its menus. pub path: Color, } @@ -33,19 +35,19 @@ pub trait StyleSheet { } /// The style of a menu bar and its menus -#[derive(Default)] +#[derive(Default, Clone)] #[allow(missing_debug_implementations)] pub enum MenuBarStyle { /// The default style. #[default] Default, /// A [`Theme`] that uses a `Custom` palette. - Custom(Box>), + Custom(Arc + Send + Sync>), } impl From Appearance> for MenuBarStyle { fn from(f: fn(&Theme) -> Appearance) -> Self { - Self::Custom(Box::new(f)) + Self::Custom(Arc::new(f)) } } @@ -69,7 +71,7 @@ impl StyleSheet for Theme { background: component.base.into(), border_width: 1.0, bar_border_radius: cosmic.corner_radii.radius_xl, - menu_border_radius: cosmic.corner_radii.radius_s, + menu_border_radius: cosmic.corner_radii.radius_s.map(|x| x + 2.0), border_color: component.divider.into(), background_expand: [1; 4], path: component.hover.into(), diff --git a/src/theme/style/mod.rs b/src/theme/style/mod.rs index a0108cbe..bc648a73 100644 --- a/src/theme/style/mod.rs +++ b/src/theme/style/mod.rs @@ -9,18 +9,30 @@ pub use self::button::Button; mod dropdown; pub mod iced; -pub use self::iced::Application; +#[doc(inline)] pub use self::iced::Checkbox; +#[doc(inline)] pub use self::iced::Container; +#[doc(inline)] pub use self::iced::ProgressBar; +#[doc(inline)] pub use self::iced::Rule; +#[doc(inline)] pub use self::iced::Svg; +#[doc(inline)] pub use self::iced::Text; pub mod menu_bar; mod segmented_button; +#[doc(inline)] pub use self::segmented_button::SegmentedButton; mod text_input; +#[doc(inline)] pub use self::text_input::TextInput; + +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +pub mod tooltip; +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +pub use tooltip::Tooltip; diff --git a/src/theme/style/segmented_button.rs b/src/theme/style/segmented_button.rs index 57fc62d5..b9863c88 100644 --- a/src/theme/style/segmented_button.rs +++ b/src/theme/style/segmented_button.rs @@ -5,16 +5,21 @@ use crate::widget::segmented_button::{Appearance, ItemAppearance, StyleSheet}; use crate::{theme::Theme, widget::segmented_button::ItemStatusAppearance}; -use iced_core::{Background, BorderRadius}; -use palette::{rgb::Rgb, Alpha}; +use iced::Border; +use iced_core::{Background, border::Radius}; +use palette::WithAlpha; #[derive(Default)] pub enum SegmentedButton { /// A tabbed widget for switching between views in an interface. #[default] - ViewSwitcher, + TabBar, /// A widget for multiple choice selection. - Selection, + Control, + /// Navigation bar style + NavBar, + /// File browser + FileNav, /// Or implement any custom theme of your liking. Custom(Box Appearance>), } @@ -24,250 +29,324 @@ impl StyleSheet for Theme { #[allow(clippy::too_many_lines)] fn horizontal(&self, style: &Self::Style) -> Appearance { + let cosmic = self.cosmic(); + let container = self.current_container(); match style { - SegmentedButton::ViewSwitcher => { - let cosmic = self.cosmic(); - let active = horizontal::view_switcher_active(cosmic); + SegmentedButton::Control => { + let rad_xl = cosmic.corner_radii.radius_xl; + let rad_0 = cosmic.corner_radii.radius_0; + let active = horizontal::selection_active(cosmic, &container.component); Appearance { - border_radius: BorderRadius::from(0.0), + background: Some(Background::Color(container.component.base.into())), + border: Border { + radius: rad_xl.into(), + ..Default::default() + }, inactive: ItemStatusAppearance { background: None, first: ItemAppearance { - border_radius: BorderRadius::from(0.0), - border_bottom: Some((1.0, cosmic.accent.base.into())), - ..Default::default() + border: Border { + radius: Radius::from([rad_xl[0], rad_0[1], rad_0[2], rad_xl[3]]), + ..Default::default() + }, }, middle: ItemAppearance { - border_radius: BorderRadius::from(0.0), - border_bottom: Some((1.0, cosmic.accent.base.into())), - ..Default::default() + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + ..Default::default() + }, }, last: ItemAppearance { - border_radius: BorderRadius::from(0.0), - border_bottom: Some((1.0, cosmic.accent.base.into())), - ..Default::default() + border: Border { + radius: Radius::from([rad_0[0], rad_xl[1], rad_xl[2], rad_0[3]]), + ..Default::default() + }, }, - text_color: cosmic.on_bg_color().into(), + text_color: container.component.on.into(), }, - hover: hover(cosmic, &active), - focus: focus(cosmic, &active), - active, - ..Default::default() - } - } - SegmentedButton::Selection => { - let cosmic = self.cosmic(); - let active = horizontal::selection_active(cosmic); - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.2; - Appearance { - border_radius: BorderRadius::from(0.0), - inactive: ItemStatusAppearance { - background: Some(Background::Color(neutral_5.into())), - first: ItemAppearance { - border_radius: BorderRadius::from([24.0, 0.0, 0.0, 24.0]), - ..Default::default() - }, - middle: ItemAppearance { - border_radius: BorderRadius::from(0.0), - ..Default::default() - }, - last: ItemAppearance { - border_radius: BorderRadius::from([0.0, 24.0, 24.0, 0.0]), - ..Default::default() - }, - text_color: cosmic.on_bg_color().into(), - }, - hover: hover(cosmic, &active), - focus: focus(cosmic, &active), + hover: hover(cosmic, &active, 0.2), + pressed: hover(cosmic, &active, 0.15), active, ..Default::default() } } + + SegmentedButton::NavBar | SegmentedButton::FileNav => Appearance { + active_width: 0.0, + ..horizontal::tab_bar(cosmic, container) + }, + + SegmentedButton::TabBar => horizontal::tab_bar(cosmic, container), + SegmentedButton::Custom(func) => func(self), } } #[allow(clippy::too_many_lines)] fn vertical(&self, style: &Self::Style) -> Appearance { + let cosmic = self.cosmic(); + let container = self.current_container(); match style { - SegmentedButton::ViewSwitcher => { - let cosmic = self.cosmic(); - let active = vertical::view_switcher_active(cosmic); + SegmentedButton::Control => { + let rad_xl = cosmic.corner_radii.radius_xl; + let rad_0 = cosmic.corner_radii.radius_0; + let active = vertical::selection_active(cosmic, &container.component); Appearance { - border_radius: BorderRadius::from(0.0), + background: Some(Background::Color(container.component.base.into())), + border: Border { + radius: rad_xl.into(), + ..Default::default() + }, inactive: ItemStatusAppearance { background: None, - text_color: cosmic.on_bg_color().into(), - ..active - }, - hover: hover(cosmic, &active), - focus: focus(cosmic, &active), - active, - ..Default::default() - } - } - SegmentedButton::Selection => { - let cosmic = self.cosmic(); - let active = vertical::selection_active(cosmic); - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.2; - Appearance { - border_radius: BorderRadius::from(0.0), - inactive: ItemStatusAppearance { - background: Some(Background::Color(neutral_5.into())), first: ItemAppearance { - border_radius: BorderRadius::from([24.0, 24.0, 0.0, 0.0]), - ..Default::default() + border: Border { + radius: Radius::from([rad_xl[0], rad_xl[1], rad_0[0], rad_0[0]]), + ..Default::default() + }, }, middle: ItemAppearance { - border_radius: BorderRadius::from(0.0), - ..Default::default() + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + ..Default::default() + }, }, last: ItemAppearance { - border_radius: BorderRadius::from([0.0, 0.0, 24.0, 24.0]), - ..Default::default() + border: Border { + radius: Radius::from([rad_0[0], rad_0[1], rad_xl[2], rad_xl[3]]), + ..Default::default() + }, }, - text_color: cosmic.on_bg_color().into(), + text_color: container.component.on.into(), }, - hover: hover(cosmic, &active), - focus: focus(cosmic, &active), + hover: hover(cosmic, &active, 0.2), + pressed: hover(cosmic, &active, 0.15), active, ..Default::default() } } + + SegmentedButton::NavBar | SegmentedButton::FileNav => Appearance { + active_width: 0.0, + ..vertical::tab_bar(cosmic, container) + }, + + SegmentedButton::TabBar => vertical::tab_bar(cosmic, container), + SegmentedButton::Custom(func) => func(self), } } } mod horizontal { + use super::Appearance; use crate::widget::segmented_button::{ItemAppearance, ItemStatusAppearance}; - use iced_core::{Background, BorderRadius}; - use palette::{rgb::Rgb, Alpha}; + use cosmic_theme::{Component, Container}; + use iced::Border; + use iced_core::{Background, border::Radius}; + use palette::WithAlpha; - pub fn selection_active(cosmic: &cosmic_theme::Theme>) -> ItemStatusAppearance { - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.2; - ItemStatusAppearance { - background: Some(Background::Color(neutral_5.into())), - first: ItemAppearance { - border_radius: BorderRadius::from([24.0, 0.0, 0.0, 24.0]), + pub fn tab_bar(cosmic: &cosmic_theme::Theme, container: &Container) -> Appearance { + let active = tab_bar_active(cosmic); + let hc = cosmic.is_high_contrast; + let border = if hc { + Border { + color: container.component.border.into(), + radius: cosmic.corner_radii.radius_0.into(), + width: 1.0, + } + } else { + Border::default() + }; + + Appearance { + active_width: 4.0, + border: Border { + radius: cosmic.corner_radii.radius_0.into(), ..Default::default() }, - middle: ItemAppearance { - border_radius: BorderRadius::from(0.0), - ..Default::default() + inactive: ItemStatusAppearance { + background: None, + first: ItemAppearance { border }, + middle: ItemAppearance { border }, + last: ItemAppearance { border }, + text_color: container.component.on.into(), }, - last: ItemAppearance { - border_radius: BorderRadius::from([0.0, 24.0, 24.0, 0.0]), - ..Default::default() - }, - text_color: cosmic.accent.base.into(), + hover: super::hover(cosmic, &active, 0.3), + pressed: super::hover(cosmic, &active, 0.25), + active, + ..Default::default() } } - pub fn view_switcher_active( - cosmic: &cosmic_theme::Theme>, + pub fn selection_active( + cosmic: &cosmic_theme::Theme, + component: &Component, ) -> ItemStatusAppearance { - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.2; + let rad_xl = cosmic.corner_radii.radius_xl; + let rad_0 = cosmic.corner_radii.radius_0; + ItemStatusAppearance { - background: Some(Background::Color(neutral_5.into())), + background: Some(Background::Color( + cosmic.palette.neutral_5.with_alpha(0.1).into(), + )), first: ItemAppearance { - border_radius: BorderRadius::from([8.0, 8.0, 0.0, 0.0]), - border_bottom: Some((4.0, cosmic.accent.base.into())), - ..Default::default() + border: Border { + radius: Radius::from([rad_xl[0], rad_0[1], rad_0[2], rad_xl[3]]), + ..Default::default() + }, }, middle: ItemAppearance { - border_radius: BorderRadius::from([8.0, 8.0, 0.0, 0.0]), - border_bottom: Some((4.0, cosmic.accent.base.into())), - ..Default::default() + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + ..Default::default() + }, }, last: ItemAppearance { - border_radius: BorderRadius::from([8.0, 8.0, 0.0, 0.0]), - border_bottom: Some((4.0, cosmic.accent.base.into())), - ..Default::default() + border: Border { + radius: Radius::from([rad_0[0], rad_xl[1], rad_xl[2], rad_0[3]]), + ..Default::default() + }, }, - text_color: cosmic.accent.base.into(), + text_color: cosmic.accent_text_color().into(), } } -} -pub fn focus( - cosmic: &cosmic_theme::Theme>, - default: &ItemStatusAppearance, -) -> ItemStatusAppearance { - // TODO: This is a hack to make the hover color lighter than the selected color - // I'm not sure why the alpha is being applied differently here than in figma - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.2; - ItemStatusAppearance { - background: Some(Background::Color(neutral_5.into())), - text_color: cosmic.accent.base.into(), - ..*default - } -} - -pub fn hover( - cosmic: &cosmic_theme::Theme>, - default: &ItemStatusAppearance, -) -> ItemStatusAppearance { - let mut neutral_10 = cosmic.palette.neutral_10; - neutral_10.alpha = 0.1; - ItemStatusAppearance { - background: Some(Background::Color(neutral_10.into())), - text_color: cosmic.accent.base.into(), - ..*default + pub fn tab_bar_active(cosmic: &cosmic_theme::Theme) -> ItemStatusAppearance { + let rad_s = cosmic.corner_radii.radius_s; + let rad_0 = cosmic.corner_radii.radius_0; + ItemStatusAppearance { + background: Some(Background::Color( + cosmic.palette.neutral_5.with_alpha(0.2).into(), + )), + first: ItemAppearance { + border: Border { + color: cosmic.accent.base.into(), + radius: Radius::from([rad_s[0], rad_s[1], rad_0[2], rad_0[3]]), + width: 0.0, + }, + }, + middle: ItemAppearance { + border: Border { + color: cosmic.accent.base.into(), + radius: Radius::from([rad_s[0], rad_s[1], rad_0[2], rad_0[3]]), + width: 0.0, + }, + }, + last: ItemAppearance { + border: Border { + color: cosmic.accent.base.into(), + radius: Radius::from([rad_s[0], rad_s[1], rad_0[2], rad_0[3]]), + width: 0.0, + }, + }, + text_color: cosmic.accent_text_color().into(), + } } } mod vertical { + use super::Appearance; use crate::widget::segmented_button::{ItemAppearance, ItemStatusAppearance}; - use iced_core::{Background, BorderRadius}; - use palette::{rgb::Rgb, Alpha}; + use cosmic_theme::{Component, Container}; + use iced::Border; + use iced_core::{Background, border::Radius}; + use palette::WithAlpha; - pub fn selection_active(cosmic: &cosmic_theme::Theme>) -> ItemStatusAppearance { - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.2; - ItemStatusAppearance { - background: Some(Background::Color(neutral_5.into())), - first: ItemAppearance { - border_radius: BorderRadius::from([24.0, 24.0, 0.0, 0.0]), + pub fn tab_bar(cosmic: &cosmic_theme::Theme, container: &Container) -> Appearance { + let active = tab_bar_active(cosmic); + Appearance { + active_width: 4.0, + border: Border { + radius: cosmic.corner_radii.radius_0.into(), ..Default::default() }, - middle: ItemAppearance { - border_radius: BorderRadius::from(0.0), - ..Default::default() + inactive: ItemStatusAppearance { + background: None, + text_color: container.component.on.into(), + ..active }, - last: ItemAppearance { - border_radius: BorderRadius::from([0.0, 0.0, 24.0, 24.0]), - ..Default::default() - }, - text_color: cosmic.accent.base.into(), + hover: super::hover(cosmic, &active, 0.3), + pressed: super::hover(cosmic, &active, 0.25), + active, + ..Default::default() } } - pub fn view_switcher_active( - cosmic: &cosmic_theme::Theme>, + pub fn selection_active( + cosmic: &cosmic_theme::Theme, + component: &Component, ) -> ItemStatusAppearance { - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.2; + let rad_0 = cosmic.corner_radii.radius_0; + let rad_xl = cosmic.corner_radii.radius_xl; + ItemStatusAppearance { - background: Some(Background::Color(neutral_5.into())), + background: Some(Background::Color( + cosmic.palette.neutral_5.with_alpha(0.1).into(), + )), first: ItemAppearance { - border_radius: BorderRadius::from(24.0), - ..Default::default() + border: Border { + radius: Radius::from([rad_xl[0], rad_xl[1], rad_0[2], rad_0[3]]), + ..Default::default() + }, }, middle: ItemAppearance { - border_radius: BorderRadius::from(24.0), - ..Default::default() + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + ..Default::default() + }, }, last: ItemAppearance { - border_radius: BorderRadius::from(24.0), - ..Default::default() + border: Border { + radius: Radius::from([rad_0[0], rad_0[1], rad_xl[2], rad_xl[3]]), + ..Default::default() + }, }, - text_color: cosmic.accent.base.into(), + text_color: cosmic.accent_text_color().into(), + } + } + + pub fn tab_bar_active(cosmic: &cosmic_theme::Theme) -> ItemStatusAppearance { + ItemStatusAppearance { + background: Some(Background::Color( + cosmic.palette.neutral_5.with_alpha(0.2).into(), + )), + first: ItemAppearance { + border: Border { + radius: cosmic.corner_radii.radius_m.into(), + width: 0.0, + ..Default::default() + }, + }, + middle: ItemAppearance { + border: Border { + radius: cosmic.corner_radii.radius_m.into(), + width: 0.0, + ..Default::default() + }, + }, + last: ItemAppearance { + border: Border { + radius: cosmic.corner_radii.radius_m.into(), + width: 0.0, + ..Default::default() + }, + }, + text_color: cosmic.accent_text_color().into(), } } } + +pub fn hover( + cosmic: &cosmic_theme::Theme, + default: &ItemStatusAppearance, + alpha: f32, +) -> ItemStatusAppearance { + ItemStatusAppearance { + background: Some(Background::Color( + cosmic.palette.neutral_5.with_alpha(alpha).into(), + )), + text_color: cosmic.accent_text_color().into(), + ..*default + } +} diff --git a/src/theme/style/text_input.rs b/src/theme/style/text_input.rs index 1970eabe..8085a48d 100644 --- a/src/theme/style/text_input.rs +++ b/src/theme/style/text_input.rs @@ -6,11 +6,13 @@ use crate::ext::ColorExt; use crate::widget::text_input::{Appearance, StyleSheet}; use iced_core::Color; +use palette::WithAlpha; #[derive(Default)] pub enum TextInput { #[default] Default, + EditableText, ExpandableSearch, Search, Inline, @@ -30,8 +32,7 @@ impl StyleSheet for crate::Theme { let palette = self.cosmic(); let container = self.current_container(); - let mut background: Color = container.component.base.into(); - background.a = 0.25; + let background: Color = container.small_widget.with_alpha(0.25).into(); let corner = palette.corner_radii; let label_color = palette.palette.neutral_9; @@ -39,11 +40,27 @@ impl StyleSheet for crate::Theme { TextInput::Default => Appearance { background: background.into(), border_radius: corner.radius_s.into(), - border_width: 1.0, + border_width: 2.0, border_offset: None, border_color: container.component.divider.into(), - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, + placeholder_color: { + let color: Color = container.on.into(); + color.blend_alpha(background, 0.7) + }, + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::EditableText => Appearance { + background: Color::TRANSPARENT.into(), + border_radius: corner.radius_0.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -58,8 +75,8 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -71,11 +88,11 @@ impl StyleSheet for crate::Theme { TextInput::Search => Appearance { background: background.into(), border_radius: corner.radius_xl.into(), - border_width: 1.0, + border_width: 2.0, border_offset: None, border_color: container.component.divider.into(), - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -90,8 +107,8 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -108,7 +125,7 @@ impl StyleSheet for crate::Theme { let palette = self.cosmic(); let container = self.current_container(); - let mut background: Color = container.component.base.into(); + let mut background: Color = container.small_widget.into(); background.a = 0.25; let corner = palette.corner_radii; @@ -118,11 +135,11 @@ impl StyleSheet for crate::Theme { TextInput::Default => Appearance { background: background.into(), border_radius: corner.radius_s.into(), - border_width: 1.0, + border_width: 2.0, border_offset: Some(2.0), border_color: Color::from(palette.destructive_color()), - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -137,8 +154,8 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -147,14 +164,14 @@ impl StyleSheet for crate::Theme { selected_fill: palette.accent_color().into(), label_color: label_color.into(), }, - TextInput::Inline => Appearance { + TextInput::EditableText | TextInput::Inline => Appearance { background: Color::TRANSPARENT.into(), border_radius: corner.radius_0.into(), border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -171,7 +188,7 @@ impl StyleSheet for crate::Theme { let palette = self.cosmic(); let container = self.current_container(); - let mut background: Color = container.component.base.into(); + let mut background: Color = container.small_widget.into(); background.a = 0.25; let corner = palette.corner_radii; @@ -181,11 +198,11 @@ impl StyleSheet for crate::Theme { TextInput::Default => Appearance { background: background.into(), border_radius: corner.radius_s.into(), - border_width: 1.0, + border_width: 2.0, border_offset: None, border_color: palette.accent.base.into(), - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -198,10 +215,10 @@ impl StyleSheet for crate::Theme { background: background.into(), border_radius: corner.radius_xl.into(), border_offset: None, - border_width: 1.0, + border_width: 2.0, border_color: palette.accent.base.into(), - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -216,8 +233,24 @@ impl StyleSheet for crate::Theme { border_offset: None, border_width: 0.0, border_color: Color::TRANSPARENT, - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, + placeholder_color: { + let color: Color = container.on.into(); + color.blend_alpha(background, 0.7) + }, + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::EditableText => Appearance { + background: Color::TRANSPARENT.into(), + border_radius: corner.radius_0.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -232,8 +265,8 @@ impl StyleSheet for crate::Theme { border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -250,7 +283,7 @@ impl StyleSheet for crate::Theme { let palette = self.cosmic(); let container = self.current_container(); - let mut background: Color = container.component.base.into(); + let mut background: Color = container.small_widget.into(); background.a = 0.25; let corner = palette.corner_radii; @@ -260,11 +293,11 @@ impl StyleSheet for crate::Theme { TextInput::Default => Appearance { background: background.into(), border_radius: corner.radius_s.into(), - border_width: 1.0, + border_width: 2.0, border_offset: Some(2.0), border_color: palette.accent.base.into(), - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -276,11 +309,27 @@ impl StyleSheet for crate::Theme { TextInput::Search | TextInput::ExpandableSearch => Appearance { background: background.into(), border_radius: corner.radius_xl.into(), - border_width: 1.0, + border_width: 2.0, border_offset: Some(2.0), border_color: palette.accent.base.into(), - icon_color: container.on.into(), - text_color: container.on.into(), + icon_color: None, + text_color: None, + placeholder_color: { + let color: Color = container.on.into(); + color.blend_alpha(background, 0.7) + }, + selected_text_color: palette.on_accent_color().into(), + selected_fill: palette.accent_color().into(), + label_color: label_color.into(), + }, + TextInput::EditableText => Appearance { + background: Color::TRANSPARENT.into(), + border_radius: corner.radius_0.into(), + border_width: 0.0, + border_offset: None, + border_color: Color::TRANSPARENT, + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -290,15 +339,13 @@ impl StyleSheet for crate::Theme { label_color: label_color.into(), }, TextInput::Inline => Appearance { - background: Color::from(palette.accent.base).into(), + background: Color::TRANSPARENT.into(), border_radius: corner.radius_0.into(), border_width: 0.0, border_offset: None, border_color: Color::TRANSPARENT, - icon_color: container.on.into(), - // TODO use regular text color here after text rendering handles multiple colors - // in this case, for selected and unselected text - text_color: palette.on_accent_color().into(), + icon_color: None, + text_color: None, placeholder_color: { let color: Color = container.on.into(); color.blend_alpha(background, 0.7) @@ -316,13 +363,6 @@ impl StyleSheet for crate::Theme { return disabled(self); } - let mut appearance = self.active(style); - - // TODO: iced will not render alpha itself on text or icon colors. - let background: Color = self.current_container().component.base.into(); - appearance.text_color = appearance.text_color.blend_alpha(background, 0.5); - appearance.icon_color = appearance.icon_color.blend_alpha(background, 0.5); - - appearance + self.active(style) } } diff --git a/src/theme/style/tooltip.rs b/src/theme/style/tooltip.rs new file mode 100644 index 00000000..a0564e63 --- /dev/null +++ b/src/theme/style/tooltip.rs @@ -0,0 +1,31 @@ +use iced::Color; + +use crate::widget::wayland::tooltip::Catalog; + +#[derive(Default)] +pub enum Tooltip { + #[default] + Default, +} + +impl Catalog for crate::Theme { + type Class = Tooltip; + + fn style(&self, style: &Self::Class) -> crate::widget::wayland::tooltip::Style { + let cosmic = self.cosmic(); + + match style { + Tooltip::Default => crate::widget::wayland::tooltip::Style { + text_color: cosmic.on_bg_color().into(), + background: None, + border_width: 0.0, + border_radius: cosmic.corner_radii.radius_0.into(), + border_color: Color::TRANSPARENT, + shadow_offset: iced::Vector::default(), + outline_width: Default::default(), + outline_color: Color::TRANSPARENT, + icon_color: None, + }, + } + } +} diff --git a/src/widget/about.rs b/src/widget/about.rs new file mode 100644 index 00000000..9b21e93a --- /dev/null +++ b/src/widget/about.rs @@ -0,0 +1,189 @@ +use crate::{ + Apply, Element, fl, + iced::{Alignment, Length}, + widget::{self, list}, +}; +use std::rc::Rc; + +#[derive(Debug, Default, Clone, derive_setters::Setters)] +#[setters(into, strip_option)] +/// Information about the application. +pub struct About { + /// The application's name. + name: Option, + /// The application's icon name. + icon: Option, + /// The application's version. + version: Option, + /// Name of the application's author. + author: Option, + /// Comments about the application. + comments: Option, + /// The application's copyright. + copyright: Option, + /// The license name. + license: Option, + /// The license url. + license_url: Option, + /// Artists who contributed to the application. + #[setters(skip)] + artists: Vec<(String, String)>, + /// Designers who contributed to the application. + #[setters(skip)] + designers: Vec<(String, String)>, + /// Developers who contributed to the application. + #[setters(skip)] + developers: Vec<(String, String)>, + /// Documenters who contributed to the application. + #[setters(skip)] + documenters: Vec<(String, String)>, + /// Translators who contributed to the application. + #[setters(skip)] + translators: Vec<(String, String)>, + /// Links associated with the application. + #[setters(skip)] + links: Vec<(String, String)>, +} + +fn add_contributors(contributors: Vec<(&str, &str)>) -> Vec<(String, String)> { + contributors + .into_iter() + .map(|(name, email)| (name.into(), format!("mailto:{email}"))) + .collect() +} + +impl<'a> About { + /// Artists who contributed to the application. + pub fn artists(mut self, contributors: impl Into>) -> Self { + self.artists = add_contributors(contributors.into()); + self + } + + /// Designers who contributed to the application. + pub fn designers(mut self, contributors: impl Into>) -> Self { + self.designers = add_contributors(contributors.into()); + self + } + + /// Developers who contributed to the application. + pub fn developers(mut self, contributors: impl Into>) -> Self { + self.developers = add_contributors(contributors.into()); + self + } + + /// Documenters who contributed to the application. + pub fn documenters(mut self, contributors: impl Into>) -> Self { + self.documenters = add_contributors(contributors.into()); + self + } + + /// Translators who contributed to the application. + pub fn translators(mut self, contributors: impl Into>) -> Self { + self.translators = add_contributors(contributors.into()); + self + } + + /// Links associated with the application. + pub fn links, V: Into>( + mut self, + links: impl IntoIterator, + ) -> Self { + self.links = links + .into_iter() + .map(|(name, url)| (name.into(), url.into())) + .collect(); + self + } +} + +/// Constructs the widget for the about section. +pub fn about<'a, Message: Clone + 'static>( + about: &'a About, + on_url_press: impl Fn(&'a str) -> Message + 'a, +) -> Element<'a, Message> { + let cosmic_theme::Spacing { + space_xxs, space_m, .. + } = crate::theme::spacing(); + + let svg_accent = Rc::new(|theme: &crate::Theme| widget::svg::Style { + color: Some(theme.cosmic().accent_text_color().into()), + }); + + let section_button = |name: &'a str, url: &'a str| -> list::ListButton<'a, Message> { + widget::row::with_capacity(2) + .push(widget::text::body(name).width(Length::Fill)) + .push_maybe( + (!url.is_empty()).then_some( + widget::icon::from_name("link-symbolic") + .icon() + .class(crate::theme::Svg::Custom(svg_accent.clone())), + ), + ) + .align_y(Alignment::Center) + .apply(list::button) + .on_press(on_url_press(url)) + }; + + let section = |list: &'a Vec<(String, String)>, title: String| { + (!list.is_empty()).then_some({ + let items = list.iter().map(|(name, url)| section_button(name, url)); + widget::settings::section().title(title).extend(items) + }) + }; + + let header_children: Vec> = [ + about.icon.as_ref().map(|i| { + i.clone() + .icon() + .size(256) + .width(Length::Fixed(128.)) + .height(Length::Fixed(128.)) + .content_fit(iced::ContentFit::Contain) + .into() + }), + about.name.as_ref().map(|n| widget::text::title3(n).into()), + about.author.as_ref().map(|a| widget::text::body(a).into()), + about.version.as_ref().map(|v| { + widget::button::standard(v) + .apply(widget::container) + .padding([space_xxs, 0, 0, 0]) + .into() + }), + ] + .into_iter() + .flatten() + .collect(); + let header = (!header_children.is_empty()) + .then_some(widget::column::with_children(header_children).align_x(Alignment::Center)); + + let links_section = section(&about.links, fl!("links")); + let developers_section = section(&about.developers, fl!("developers")); + let designers_section = section(&about.designers, fl!("designers")); + let artists_section = section(&about.artists, fl!("artists")); + let translators_section = section(&about.translators, fl!("translators")); + let documenters_section = section(&about.documenters, fl!("documenters")); + let license_section = about.license.as_ref().map(|license| { + let url = about.license_url.as_deref().unwrap_or_default(); + widget::settings::section() + .title(fl!("license")) + .add(section_button(license, url)) + }); + let copyright = about.copyright.as_ref().map(widget::text::body); + let comments = about.comments.as_ref().map(widget::text::body); + + widget::column::with_capacity(10) + .push_maybe(header) + .push_maybe(links_section) + .push_maybe(developers_section) + .push_maybe(designers_section) + .push_maybe(artists_section) + .push_maybe(translators_section) + .push_maybe(documenters_section) + .push_maybe(license_section) + .push_maybe(comments) + .push_maybe(copyright) + .spacing(space_m) + .width(Length::Fill) + .align_x(Alignment::Center) + .into() +} diff --git a/src/widget/aspect_ratio.rs b/src/widget/aspect_ratio.rs index 076abdb6..577bea95 100644 --- a/src/widget/aspect_ratio.rs +++ b/src/widget/aspect_ratio.rs @@ -1,43 +1,42 @@ -use iced::widget::Container; +//! A container which constraints itself to a specific aspect ratio. + use iced::Size; -use iced_core::alignment; -use iced_core::event::{self, Event}; +use iced::widget::Container; +use iced_core::event::Event; use iced_core::layout; use iced_core::mouse; use iced_core::overlay; use iced_core::renderer; use iced_core::widget::Tree; -use iced_core::{Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Widget}; +use iced_core::{ + Alignment, Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Vector, Widget, +}; -pub use iced_style::container::{Appearance, StyleSheet}; +pub use iced_widget::container::{Catalog, Style}; pub fn aspect_ratio_container<'a, Message: 'static, T>( content: T, ratio: f32, ) -> AspectRatio<'a, Message, crate::Renderer> where - T: Into>, + T: Into>, { AspectRatio::new(content, ratio) } -/// An element decorating some content. -/// -/// It is normally used for alignment purposes. +/// A container which constraints itself to a specific aspect ratio. #[allow(missing_debug_implementations)] pub struct AspectRatio<'a, Message, Renderer> where Renderer: iced_core::Renderer, - Renderer::Theme: StyleSheet, { ratio: f32, - container: Container<'a, Message, Renderer>, + container: Container<'a, Message, crate::Theme, Renderer>, } -impl<'a, Message, Renderer> AspectRatio<'a, Message, Renderer> +impl AspectRatio<'_, Message, Renderer> where Renderer: iced_core::Renderer, - Renderer::Theme: StyleSheet, { fn constrain_limits(&self, size: Size) -> Size { let Size { @@ -56,12 +55,11 @@ where impl<'a, Message, Renderer> AspectRatio<'a, Message, Renderer> where Renderer: iced_core::Renderer, - Renderer::Theme: StyleSheet, { /// Creates an empty [`Container`]. pub(crate) fn new(content: T, ratio: f32) -> Self where - T: Into>, + T: Into>, { AspectRatio { ratio, @@ -78,6 +76,7 @@ where /// Sets the width of the [`self.`]. #[must_use] + #[inline] pub fn width(mut self, width: Length) -> Self { self.container = self.container.width(width); self @@ -85,6 +84,7 @@ where /// Sets the height of the [`Container`]. #[must_use] + #[inline] pub fn height(mut self, height: Length) -> Self { self.container = self.container.height(height); self @@ -92,6 +92,7 @@ where /// Sets the maximum width of the [`Container`]. #[must_use] + #[inline] pub fn max_width(mut self, max_width: f32) -> Self { self.container = self.container.max_width(max_width); self @@ -99,6 +100,7 @@ where /// Sets the maximum height of the [`Container`] in pixels. #[must_use] + #[inline] pub fn max_height(mut self, max_height: f32) -> Self { self.container = self.container.max_height(max_height); self @@ -106,44 +108,56 @@ where /// Sets the content alignment for the horizontal axis of the [`Container`]. #[must_use] - pub fn align_x(mut self, alignment: alignment::Horizontal) -> Self { + #[inline] + pub fn align_x(mut self, alignment: Alignment) -> Self { self.container = self.container.align_x(alignment); self } /// Sets the content alignment for the vertical axis of the [`Container`]. #[must_use] - pub fn align_y(mut self, alignment: alignment::Vertical) -> Self { + #[inline] + pub fn align_y(mut self, alignment: Alignment) -> Self { self.container = self.container.align_y(alignment); self } /// Centers the contents in the horizontal axis of the [`Container`]. #[must_use] - pub fn center_x(mut self) -> Self { - self.container = self.container.center_x(); + #[inline] + pub fn center_x(mut self, width: Length) -> Self { + self.container = self.container.center_x(width); self } /// Centers the contents in the vertical axis of the [`Container`]. #[must_use] - pub fn center_y(mut self) -> Self { - self.container = self.container.center_y(); + #[inline] + pub fn center_y(mut self, height: Length) -> Self { + self.container = self.container.center_y(height); + self + } + + /// Centers the contents in the horizontal and vertical axis of the [`Container`]. + #[must_use] + #[inline] + pub fn center(mut self, length: Length) -> Self { + self.container = self.container.center(length); self } /// Sets the style of the [`Container`]. #[must_use] - pub fn style(mut self, style: impl Into<::Style>) -> Self { - self.container = self.container.style(style); + pub fn class(mut self, style: impl Into>) -> Self { + self.container = self.container.class(style); self } } -impl<'a, Message, Renderer> Widget for AspectRatio<'a, Message, Renderer> +impl Widget + for AspectRatio<'_, Message, Renderer> where Renderer: iced_core::Renderer, - Renderer::Theme: StyleSheet, { fn children(&self) -> Vec { self.container.children() @@ -153,46 +167,46 @@ where self.container.diff(tree); } - fn width(&self) -> Length { - Widget::width(&self.container) + fn size(&self) -> Size { + self.container.size() } - fn height(&self) -> Length { - Widget::height(&self.container) - } - - fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { let custom_limits = layout::Limits::new( self.constrain_limits(limits.min()), self.constrain_limits(limits.max()), ); - self.container.layout(renderer, &custom_limits) + self.container + .layout(&mut tree.children[0], renderer, &custom_limits) } fn operate( - &self, + &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn iced_core::widget::Operation< - iced_core::widget::OperationOutputWrapper, - >, + operation: &mut dyn iced_core::widget::Operation<()>, ) { self.container.operate(tree, layout, renderer, operation); } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor_position: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { - self.container.on_event( + ) { + self.container.update( tree, event, layout, @@ -220,7 +234,7 @@ where &self, tree: &Tree, renderer: &mut Renderer, - theme: &Renderer::Theme, + theme: &crate::Theme, renderer_style: &renderer::Style, layout: Layout<'_>, cursor_position: mouse::Cursor, @@ -240,21 +254,36 @@ where fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, - ) -> Option> { - self.container.overlay(tree, layout, renderer) + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.container + .overlay(tree, layout, renderer, viewport, translation) + } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: Layout<'_>, + state: &Tree, + p: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + self.container.a11y_nodes(layout, state, p) } } impl<'a, Message, Renderer> From> - for Element<'a, Message, Renderer> + for Element<'a, Message, crate::Theme, Renderer> where Message: 'a, Renderer: 'a + iced_core::Renderer, - Renderer::Theme: StyleSheet, { - fn from(column: AspectRatio<'a, Message, Renderer>) -> Element<'a, Message, Renderer> { + fn from( + column: AspectRatio<'a, Message, Renderer>, + ) -> Element<'a, Message, crate::Theme, Renderer> { Element::new(column) } } diff --git a/src/widget/autosize.rs b/src/widget/autosize.rs new file mode 100644 index 00000000..69fd9c83 --- /dev/null +++ b/src/widget/autosize.rs @@ -0,0 +1,312 @@ +//! Autosize Container, which will resize the window to its contents. + +use iced_core::event::{self, Event}; +use iced_core::layout; +use iced_core::mouse; +use iced_core::overlay; +use iced_core::renderer; +use iced_core::widget::{Id, Operation, Tree}; +use iced_core::{Clipboard, Element, Layout, Length, Rectangle, Shell, Vector, Widget}; +pub use iced_widget::container::{Catalog, Style}; + +pub fn autosize<'a, Message: 'static, Theme, E>( + content: E, + id: Id, +) -> Autosize<'a, Message, Theme, crate::Renderer> +where + E: Into>, + Theme: iced_widget::container::Catalog, + ::Class<'a>: From>, +{ + Autosize::new(content, id) +} + +/// An element decorating some content. +/// +/// It is normally used for alignment purposes. +#[allow(missing_debug_implementations)] +pub struct Autosize<'a, Message, Theme, Renderer> +where + Renderer: iced_core::Renderer, +{ + content: Element<'a, Message, Theme, Renderer>, + id: Id, + limits: layout::Limits, + auto_width: bool, + auto_height: bool, +} + +impl<'a, Message, Theme, Renderer> Autosize<'a, Message, Theme, Renderer> +where + Renderer: iced_core::Renderer, +{ + /// Creates an empty [`IdContainer`]. + pub(crate) fn new(content: T, id: Id) -> Self + where + T: Into>, + { + Autosize { + content: content.into(), + id, + limits: layout::Limits::NONE, + auto_width: true, + auto_height: true, + } + } + + #[inline] + pub fn limits(mut self, limits: layout::Limits) -> Self { + self.limits = limits; + self + } + + #[inline] + pub fn auto_width(mut self, auto_width: bool) -> Self { + self.auto_width = auto_width; + self + } + + #[inline] + pub fn auto_height(mut self, auto_height: bool) -> Self { + self.auto_height = auto_height; + self + } + + #[inline] + pub fn max_width(mut self, v: f32) -> Self { + self.limits = self.limits.max_width(v); + self + } + + #[inline] + pub fn max_height(mut self, v: f32) -> Self { + self.limits = self.limits.max_height(v); + self + } + + #[inline] + pub fn min_width(mut self, v: f32) -> Self { + self.limits = self.limits.min_width(v); + self + } + + #[inline] + pub fn min_height(mut self, v: f32) -> Self { + self.limits = self.limits.min_height(v); + self + } +} + +impl Widget + for Autosize<'_, Message, Theme, Renderer> +where + Renderer: iced_core::Renderer, +{ + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&mut self, tree: &mut Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + + fn size(&self) -> iced_core::Size { + self.content.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let mut my_limits = self.limits; + let min = limits.min(); + let max = limits.max(); + if !self.auto_width { + my_limits = limits.min_width(min.width).max_width(max.width); + } + if !self.auto_height { + my_limits = limits.min_height(min.height).max_height(max.height); + } + let node = self + .content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, &my_limits); + let size = node.size(); + layout::Node::with_children(size, vec![node]) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(Some(&self.id), layout.bounds()); + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout + .children() + .next() + .unwrap() + .with_virtual_offset(layout.virtual_offset()), + renderer, + operation, + ); + }); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + #[cfg(all(feature = "wayland", target_os = "linux"))] + if matches!( + event, + Event::PlatformSpecific(event::PlatformSpecific::Wayland( + event::wayland::Event::RequestResize + )) + ) { + let bounds = layout.bounds().size(); + clipboard.request_logical_window_size(bounds.width.max(1.), bounds.height.max(1.)); + } + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout + .children() + .next() + .unwrap() + .with_virtual_offset(layout.virtual_offset()), + cursor_position, + renderer, + clipboard, + shell, + viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let content_layout = layout.children().next().unwrap(); + self.content.as_widget().mouse_interaction( + &tree.children[0], + content_layout.with_virtual_offset(layout.virtual_offset()), + cursor_position, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + viewport: &Rectangle, + ) { + let content_layout = layout.children().next().unwrap(); + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + renderer_style, + content_layout.with_virtual_offset(layout.virtual_offset()), + cursor_position, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout + .children() + .next() + .unwrap() + .with_virtual_offset(layout.virtual_offset()), + renderer, + viewport, + translation, + ) + } + + fn drag_destinations( + &self, + state: &Tree, + layout: Layout<'_>, + renderer: &Renderer, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, + ) { + let content_layout = layout.children().next().unwrap(); + self.content.as_widget().drag_destinations( + &state.children[0], + content_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + dnd_rectangles, + ); + } + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn set_id(&mut self, id: crate::widget::Id) { + self.id = id; + } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: Layout<'_>, + state: &Tree, + p: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + let c_layout = layout.children().next().unwrap(); + let c_state = &state.children[0]; + self.content.as_widget().a11y_nodes( + c_layout.with_virtual_offset(layout.virtual_offset()), + c_state, + p, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Renderer: 'a + iced_core::Renderer, + Theme: 'a, +{ + fn from(c: Autosize<'a, Message, Theme, Renderer>) -> Element<'a, Message, Theme, Renderer> { + Element::new(c) + } +} diff --git a/src/widget/button/icon.rs b/src/widget/button/icon.rs index 9a70a510..04d2bdd5 100644 --- a/src/widget/button/icon.rs +++ b/src/widget/button/icon.rs @@ -1,144 +1,138 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use super::{button, Builder, Style}; -use crate::widget::{ - icon::{self, Handle}, - tooltip, -}; +use super::{Builder, ButtonClass}; use crate::Element; +use crate::widget::{icon::Handle, tooltip}; use apply::Apply; -use iced_core::{font::Weight, text::LineHeight, widget::Id, Alignment, Length, Padding}; +use iced_core::{Alignment, Length, Padding, font::Weight, text::LineHeight, widget::Id}; use std::borrow::Cow; pub type Button<'a, Message> = Builder<'a, Message, Icon>; +/// The icon variant of a button. pub struct Icon { handle: Handle, vertical: bool, + selected: bool, } +/// A button constructed from an icon handle, using icon button styling. pub fn icon<'a, Message>(handle: impl Into) -> Button<'a, Message> { Button::new(Icon { handle: handle.into(), vertical: false, + selected: false, }) } -impl<'a, Message> Button<'a, Message> { +impl Button<'_, Message> { pub fn new(icon: Icon) -> Self { - crate::theme::THEME.with(|theme_cell| { - let theme = theme_cell.borrow(); - let theme = theme.cosmic(); - let padding = theme.space_xxs(); + let guard = crate::theme::THEME.lock().unwrap(); + let theme = guard.cosmic(); + let padding = theme.space_xxs(); - Self { - id: Id::unique(), - label: Cow::Borrowed(""), - tooltip: Cow::Borrowed(""), - on_press: None, - width: Length::Shrink, - height: Length::Fixed(46.0), - padding: Padding::from(padding), - spacing: theme.space_xxxs(), - icon_size: if icon.handle.symbolic { 16 } else { 24 }, - line_height: 20, - font_size: 14, - font_weight: Weight::Normal, - style: Style::Icon, - variant: icon, - } - }) + Self { + id: Id::unique(), + label: Cow::Borrowed(""), + #[cfg(feature = "a11y")] + name: Cow::Borrowed(""), + #[cfg(feature = "a11y")] + description: Cow::Borrowed(""), + tooltip: Cow::Borrowed(""), + on_press: None, + width: Length::Shrink, + height: Length::Shrink, + padding: Padding::from(padding), + spacing: theme.space_xxxs(), + icon_size: if icon.handle.symbolic { 16 } else { 24 }, + line_height: 20, + font_size: 14, + font_weight: Weight::Normal, + class: ButtonClass::Icon, + variant: icon, + } } /// Applies the **Extra Small** button size preset. pub fn extra_small(mut self) -> Self { - crate::theme::THEME.with(|theme_cell| { - let theme = theme_cell.borrow(); - let theme = theme.cosmic(); + let guard = crate::theme::THEME.lock().unwrap(); + let theme = guard.cosmic(); - self.font_size = 14; - self.font_weight = Weight::Normal; - self.icon_size = 16; - self.line_height = 20; - self.height = Length::Fixed(36.0); - self.padding = Padding::from(theme.space_xxs()); - self.spacing = theme.space_xxxs(); - }); + self.font_size = 14; + self.font_weight = Weight::Normal; + self.icon_size = 16; + self.line_height = 20; + self.padding = Padding::from(theme.space_xxs()); + self.spacing = theme.space_xxxs(); self } /// Applies the **Medium** button size preset. pub fn medium(mut self) -> Self { - crate::theme::THEME.with(|theme_cell| { - let theme = theme_cell.borrow(); - let theme = theme.cosmic(); + let guard = crate::theme::THEME.lock().unwrap(); + let theme = guard.cosmic(); - self.font_size = 24; - self.font_weight = Weight::Normal; - self.icon_size = 32; - self.line_height = 32; - self.height = Length::Fixed(56.0); - self.padding = Padding::from(theme.space_xs()); - self.spacing = theme.space_xxs(); - }); + self.font_size = 24; + self.font_weight = Weight::Normal; + self.icon_size = 32; + self.line_height = 32; + self.padding = Padding::from(theme.space_xs()); + self.spacing = theme.space_xxs(); self } /// Applies the **Large** button size preset. pub fn large(mut self) -> Self { - crate::theme::THEME.with(|theme_cell| { - let theme = theme_cell.borrow(); - let theme = theme.cosmic(); + let guard = crate::theme::THEME.lock().unwrap(); + let theme = guard.cosmic(); - self.font_size = 28; - self.font_weight = Weight::Normal; - self.icon_size = 40; - self.line_height = 36; - self.height = Length::Fixed(64.0); - self.padding = Padding::from(theme.space_xs()); - self.spacing = theme.space_xxs(); - }); + self.font_size = 28; + self.font_weight = Weight::Normal; + self.icon_size = 40; + self.line_height = 36; + self.padding = Padding::from(theme.space_xs()); + self.spacing = theme.space_xxs(); self } /// Applies the **Extra Large** button size preset. pub fn extra_large(mut self) -> Self { - crate::theme::THEME.with(|theme_cell| { - let theme = theme_cell.borrow(); - let theme = theme.cosmic(); - let padding = theme.space_xs(); + let guard = crate::theme::THEME.lock().unwrap(); + let theme = guard.cosmic(); + let padding = theme.space_xs(); - self.font_size = 32; - self.font_weight = Weight::Light; - self.icon_size = 56; - self.line_height = 44; - self.height = Length::Fixed(80.0); - self.padding = Padding::from(padding); - self.spacing = theme.space_xxs(); - }); + self.font_size = 32; + self.font_weight = Weight::Light; + self.icon_size = 56; + self.line_height = 44; + self.padding = Padding::from(padding); + self.spacing = theme.space_xxs(); self } + #[inline] + pub fn selected(mut self, selected: bool) -> Self { + self.variant.selected = selected; + self + } + + #[inline] pub fn vertical(mut self, vertical: bool) -> Self { self.variant.vertical = vertical; - self.style = Style::IconVertical; + self.class = ButtonClass::IconVertical; self } } impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { - fn from(mut builder: Button<'a, Message>) -> Element<'a, Message> { + fn from(builder: Button<'a, Message>) -> Element<'a, Message> { let mut content = Vec::with_capacity(2); - if let icon::Data::Name(ref mut named) = builder.variant.handle.data { - named.size = Some(builder.icon_size); - } - content.push( crate::widget::icon(builder.variant.handle.clone()) .size(builder.icon_size) @@ -150,48 +144,56 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes crate::widget::text(builder.label) .size(builder.font_size) .line_height(LineHeight::Absolute(builder.line_height.into())) - .font({ - let mut font = crate::font::DEFAULT; - font.weight = builder.font_weight; - font + .font(crate::font::Font { + weight: builder.font_weight, + ..crate::font::default() }) .into(), ); } - let button = if builder.variant.vertical { + let mut button = if builder.variant.vertical { crate::widget::column::with_children(content) .padding(builder.padding) .spacing(builder.spacing) - .align_items(Alignment::Center) - .apply(button) + .align_x(Alignment::Center) + .apply(super::custom) } else { crate::widget::row::with_children(content) .padding(builder.padding) .width(builder.width) .height(builder.height) .spacing(builder.spacing) - .align_items(Alignment::Center) - .apply(button) + .align_y(Alignment::Center) + .apply(super::custom) }; + #[cfg(feature = "a11y")] + { + button = button.name(builder.name).description(builder.description); + } + let button = button .padding(0) .id(builder.id) .on_press_maybe(builder.on_press) - .style(builder.style); + .selected(builder.variant.selected) + .class(builder.class); if builder.tooltip.is_empty() { button.into() } else { - tooltip(button, builder.tooltip, tooltip::Position::Top) - .size(builder.font_size) - .font({ - let mut font = crate::font::DEFAULT; - font.weight = builder.font_weight; - font - }) - .into() + tooltip( + button, + crate::widget::text(builder.tooltip) + .size(builder.font_size) + .font(crate::font::Font { + weight: builder.font_weight, + ..crate::font::default() + }), + tooltip::Position::Top, + ) + .into() } } } diff --git a/src/widget/button/image.rs b/src/widget/button/image.rs index 9bfa2880..ab51e667 100644 --- a/src/widget/button/image.rs +++ b/src/widget/button/image.rs @@ -1,16 +1,17 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use super::{Builder, Style}; +use super::Builder; use crate::{ - widget::{self, image::Handle}, Element, + widget::{self, image::Handle}, }; -use iced_core::{font::Weight, widget::Id, Length, Padding}; +use iced_core::{Length, Padding, font::Weight, widget::Id}; use std::borrow::Cow; pub type Button<'a, Message> = Builder<'a, Message, Image<'a, Handle, Message>>; +/// A button constructed from an image handle, using image button styling. pub fn image<'a, Message>(handle: impl Into + 'a) -> Button<'a, Message> { Button::new(Image { image: widget::image(handle).border_radius([9.0; 4]), @@ -19,6 +20,7 @@ pub fn image<'a, Message>(handle: impl Into + 'a) -> Button<'a, Message> }) } +/// The image variant of a button. pub struct Image<'a, Handle, Message> { image: widget::Image<'a, Handle>, selected: bool, @@ -26,10 +28,15 @@ pub struct Image<'a, Handle, Message> { } impl<'a, Message> Button<'a, Message> { + #[inline] pub fn new(variant: Image<'a, Handle, Message>) -> Self { Self { id: Id::unique(), label: Cow::Borrowed(""), + #[cfg(feature = "a11y")] + name: Cow::Borrowed(""), + #[cfg(feature = "a11y")] + description: Cow::Borrowed(""), tooltip: Cow::Borrowed(""), on_press: None, width: Length::Shrink, @@ -40,21 +47,24 @@ impl<'a, Message> Button<'a, Message> { line_height: 20, font_size: 14, font_weight: Weight::Normal, - style: Style::Image, + class: crate::theme::style::Button::Image, variant, } } + #[inline] pub fn on_remove(mut self, message: Message) -> Self { self.variant.on_remove = Some(message); self } + #[inline] pub fn on_remove_maybe(mut self, message: Option) -> Self { self.variant.on_remove = message; self } + #[inline] pub fn selected(mut self, selected: bool) -> Self { self.variant.selected = selected; self @@ -73,12 +83,18 @@ where .width(builder.width) .height(builder.height); - super::custom_image_button(content, builder.variant.on_remove) + let mut button = super::custom_image_button(content, builder.variant.on_remove) .padding(0) .selected(builder.variant.selected) .id(builder.id) .on_press_maybe(builder.on_press) - .style(builder.style) - .into() + .class(builder.class); + + #[cfg(feature = "a11y")] + { + button = button.name(builder.name).description(builder.description); + } + + button.into() } } diff --git a/src/widget/button/link.rs b/src/widget/button/link.rs index 960d70a8..9ce81268 100644 --- a/src/widget/button/link.rs +++ b/src/widget/button/link.rs @@ -1,14 +1,16 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 +//! Hyperlink button widget + use super::Builder; -use super::Style; +use super::ButtonClass; +use crate::Element; use crate::prelude::*; use crate::widget::icon::{self, Handle}; use crate::widget::{button, row, tooltip}; -use crate::Element; use iced_core::text::LineHeight; -use iced_core::{font::Weight, widget::Id, Alignment, Length, Padding}; +use iced_core::{Alignment, Length, Padding, font::Weight, widget::Id}; use std::borrow::Cow; pub type Button<'a, Message> = Builder<'a, Message, Hyperlink>; @@ -17,6 +19,7 @@ pub struct Hyperlink { trailing_icon: bool, } +/// A hyperlink button. pub fn link<'a, Message>(label: impl Into> + 'static) -> Button<'a, Message> { Button::new( label, @@ -31,6 +34,10 @@ impl<'a, Message> Button<'a, Message> { Self { id: Id::unique(), label: label.into(), + #[cfg(feature = "a11y")] + name: Cow::Borrowed(""), + #[cfg(feature = "a11y")] + description: Cow::Borrowed(""), tooltip: Cow::Borrowed(""), on_press: None, width: Length::Shrink, @@ -41,7 +48,7 @@ impl<'a, Message> Button<'a, Message> { line_height: 20, font_size: 14, font_weight: Weight::Normal, - style: Style::Link, + class: ButtonClass::Link, variant: link, } } @@ -52,22 +59,23 @@ impl<'a, Message> Button<'a, Message> { } } +#[inline(never)] pub fn icon() -> Handle { icon::from_svg_bytes(&include_bytes!("external-link.svg")[..]).symbolic(true) } impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { fn from(mut builder: Button<'a, Message>) -> Element<'a, Message> { - let button: super::Button<'a, Message, crate::Renderer> = row::with_capacity(2) + let mut button: super::Button<'a, Message> = row::with_capacity(2) .push({ - let mut font = crate::font::DEFAULT; - font.weight = builder.font_weight; - // TODO: Avoid allocation crate::widget::text(builder.label.to_string()) .size(builder.font_size) .line_height(LineHeight::Absolute(builder.line_height.into())) - .font(font) + .font(crate::font::Font { + weight: builder.font_weight, + ..crate::font::default() + }) }) .push_maybe(if builder.variant.trailing_icon { Some(icon().icon().size(builder.icon_size)) @@ -78,24 +86,36 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes .width(builder.width) .height(builder.height) .spacing(builder.spacing) - .align_items(Alignment::Center) - .apply(button) + .align_y(Alignment::Center) + .apply(button::custom) .padding(0) .id(builder.id) .on_press_maybe(builder.on_press.take()) - .style(builder.style); + .class(builder.class); + + #[cfg(feature = "a11y")] + { + if !builder.label.is_empty() { + button = button.name(builder.label); + } + + button = button.description(builder.description); + } if builder.tooltip.is_empty() { button.into() } else { - tooltip(button, builder.tooltip, tooltip::Position::Top) - .size(builder.font_size) - .font({ - let mut font = crate::font::DEFAULT; - font.weight = builder.font_weight; - font - }) - .into() + tooltip( + button, + crate::widget::text(builder.tooltip) + .size(builder.font_size) + .font(crate::font::Font { + weight: builder.font_weight, + ..crate::font::default() + }), + tooltip::Position::Top, + ) + .into() } } } diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs index 32872e0e..f5975d39 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -1,50 +1,64 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -pub use crate::theme::Button as Style; +//! Button widgets for COSMIC applications. + +pub use crate::theme::Button as ButtonClass; pub mod link; use derive_setters::Setters; -pub use link::link; +#[doc(inline)] pub use link::Button as LinkButton; +#[doc(inline)] +pub use link::link; mod icon; -pub use icon::icon; +#[doc(inline)] pub use icon::Button as IconButton; +#[doc(inline)] +pub use icon::icon; mod image; -pub use image::image; +#[doc(inline)] pub use image::Button as ImageButton; +#[doc(inline)] +pub use image::image; mod style; -pub use style::{Appearance, StyleSheet}; +#[doc(inline)] +pub use style::{Catalog, Style}; mod text; +#[doc(inline)] pub use text::Button as TextButton; +#[doc(inline)] pub use text::{destructive, standard, suggested, text}; mod widget; -pub use widget::{draw, focus, layout, mouse_interaction, Button}; +#[doc(inline)] +pub use widget::{Button, draw, focus, layout, mouse_interaction}; -use crate::Element; use iced_core::font::Weight; use iced_core::widget::Id; use iced_core::{Length, Padding}; use std::borrow::Cow; -pub fn button<'a, Message>( - content: impl Into>, -) -> Button<'a, Message, crate::Renderer> { - Button::new(content) +/// A button with a custom element for its content. +pub fn custom<'a, Message: Clone + 'a>( + content: impl Into>, +) -> Button<'a, Message> { + Button::new(content.into()) } -pub fn custom_image_button<'a, Message>( - content: impl Into>, +/// An image button which may contain any widget as its content. +pub fn custom_image_button<'a, Message: Clone + 'a>( + content: impl Into>, on_remove: Option, -) -> Button<'a, Message, crate::Renderer> { - Button::new_image(content, on_remove) +) -> Button<'a, Message> { + Button::new_image(content.into(), on_remove) } +/// A builder for constructing a custom [`Button`]. #[must_use] #[derive(Setters)] pub struct Builder<'a, Message, Variant> { @@ -55,6 +69,16 @@ pub struct Builder<'a, Message, Variant> { #[setters(into)] label: Cow<'a, str>, + /// A name for screen reader support + #[cfg(feature = "a11y")] + #[setters(into)] + name: Cow<'a, str>, + + /// A description for screen reader support + #[cfg(feature = "a11y")] + #[setters(into)] + description: Cow<'a, str>, + // Adds a tooltip to the button. #[setters(into)] tooltip: Cow<'a, str>, @@ -93,13 +117,13 @@ pub struct Builder<'a, Message, Variant> { font_weight: Weight, /// The preferred style of the button. - style: Style, + class: ButtonClass, #[setters(skip)] variant: Variant, } -impl<'a, Message, Variant> Builder<'a, Message, Variant> { +impl Builder<'_, Message, Variant> { /// Set the value of [`on_press`] as either `Some` or `None`. pub fn on_press_maybe(mut self, on_press: Option) -> Self { self.on_press = on_press; diff --git a/src/widget/button/style.rs b/src/widget/button/style.rs index 644aedb6..21afa08b 100644 --- a/src/widget/button/style.rs +++ b/src/widget/button/style.rs @@ -2,20 +2,25 @@ // SPDX-License-Identifier: MPL-2.0 //! Change the apperance of a button. -use iced_core::{Background, BorderRadius, Color, Vector}; +use iced_core::{Background, Color, Vector, border::Radius}; + +use crate::theme::THEME; /// The appearance of a button. #[must_use] #[derive(Debug, Clone, Copy)] -pub struct Appearance { +pub struct Style { /// The amount of offset to apply to the shadow of the button. pub shadow_offset: Vector, /// The [`Background`] of the button. pub background: Option, + /// The [`Background`] overlay of the button. + pub overlay: Option, + /// The border radius of the button. - pub border_radius: BorderRadius, + pub border_radius: Radius, /// The border width of the button. pub border_width: f32, @@ -36,50 +41,53 @@ pub struct Appearance { pub text_color: Option, } -impl Appearance { - // TODO: `BorderRadius` is not `const fn` compatible. +impl Style { + // TODO: `Radius` is not `const fn` compatible. pub fn new() -> Self { + let rad_0 = THEME.lock().unwrap().cosmic().corner_radii.radius_0; Self { shadow_offset: Vector::new(0.0, 0.0), background: None, - border_radius: BorderRadius::from(0.0), + border_radius: Radius::from(rad_0), border_width: 0.0, border_color: Color::TRANSPARENT, outline_width: 0.0, outline_color: Color::TRANSPARENT, icon_color: None, text_color: None, + overlay: None, } } } -impl std::default::Default for Appearance { +impl std::default::Default for Style { fn default() -> Self { Self::new() } } +// TODO update to match other styles /// A set of rules that dictate the style of a button. -pub trait StyleSheet { +pub trait Catalog { /// The supported style of the [`StyleSheet`]. - type Style: Default; + type Class: Default; /// Produces the active [`Appearance`] of a button. - fn active(&self, focused: bool, style: &Self::Style) -> Appearance; + fn active(&self, focused: bool, selected: bool, style: &Self::Class) -> Style; /// Produces the disabled [`Appearance`] of a button. - fn disabled(&self, style: &Self::Style) -> Appearance; + fn disabled(&self, style: &Self::Class) -> Style; /// [`Appearance`] when the button is the target of a DND operation. - fn drop_target(&self, style: &Self::Style) -> Appearance { - self.hovered(false, style) + fn drop_target(&self, style: &Self::Class) -> Style { + self.hovered(false, false, style) } /// Produces the hovered [`Appearance`] of a button. - fn hovered(&self, focused: bool, style: &Self::Style) -> Appearance; + fn hovered(&self, focused: bool, selected: bool, style: &Self::Class) -> Style; /// Produces the pressed [`Appearance`] of a button. - fn pressed(&self, focused: bool, style: &Self::Style) -> Appearance; + fn pressed(&self, focused: bool, selected: bool, style: &Self::Class) -> Style; /// Background color of the selection indicator fn selection_background(&self) -> Background; diff --git a/src/widget/button/text.rs b/src/widget/button/text.rs index 4bdb581c..bcdd02ba 100644 --- a/src/widget/button/text.rs +++ b/src/widget/button/text.rs @@ -1,40 +1,52 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use super::{button, Builder, Style}; +use super::{Builder, ButtonClass}; use crate::widget::{icon, row, tooltip}; -use crate::{ext::CollectionWidget, Element}; -use apply::Apply; -use iced_core::{font::Weight, text::LineHeight, widget::Id, Alignment, Length, Padding}; +use crate::{Apply, Element}; +use iced_core::{Alignment, Length, Padding, font::Weight, text::LineHeight, widget::Id}; use std::borrow::Cow; pub type Button<'a, Message> = Builder<'a, Message, Text>; +/// A text button with the destructive style pub fn destructive<'a, Message>(label: impl Into>) -> Button<'a, Message> { Button::new(Text::new()) .label(label) - .style(Style::Destructive) + .class(ButtonClass::Destructive) } +/// A text button with the suggested style pub fn suggested<'a, Message>(label: impl Into>) -> Button<'a, Message> { Button::new(Text::new()) .label(label) - .style(Style::Suggested) + .class(ButtonClass::Suggested) } +/// A text button with the standard style pub fn standard<'a, Message>(label: impl Into>) -> Button<'a, Message> { Button::new(Text::new()).label(label) } +/// A text button with the text style pub fn text<'a, Message>(label: impl Into>) -> Button<'a, Message> { - Button::new(Text::new()).label(label).style(Style::Text) + Button::new(Text::new()) + .label(label) + .class(ButtonClass::Text) } +/// The text variant of a button. pub struct Text { pub(super) leading_icon: Option, pub(super) trailing_icon: Option, } +impl Default for Text { + fn default() -> Self { + Self::new() + } +} + impl Text { pub const fn new() -> Self { Self { @@ -44,28 +56,30 @@ impl Text { } } -impl<'a, Message> Button<'a, Message> { +impl Button<'_, Message> { pub fn new(text: Text) -> Self { - crate::theme::THEME.with(|theme_cell| { - let theme = theme_cell.borrow(); - let theme = theme.cosmic(); - Self { - id: Id::unique(), - label: Cow::Borrowed(""), - tooltip: Cow::Borrowed(""), - on_press: None, - width: Length::Shrink, - height: Length::Fixed(theme.space_l().into()), - padding: Padding::from([0, theme.space_s()]), - spacing: theme.space_xxxs(), - icon_size: 16, - line_height: 20, - font_size: 14, - font_weight: Weight::Normal, - style: Style::Standard, - variant: text, - } - }) + let guard = crate::theme::THEME.lock().unwrap(); + let theme = guard.cosmic(); + Self { + id: Id::unique(), + label: Cow::Borrowed(""), + #[cfg(feature = "a11y")] + name: Cow::Borrowed(""), + #[cfg(feature = "a11y")] + description: Cow::Borrowed(""), + tooltip: Cow::Borrowed(""), + on_press: None, + width: Length::Shrink, + height: Length::Fixed(theme.space_l().into()), + padding: Padding::from([0, theme.space_s()]), + spacing: theme.space_xxxs(), + icon_size: 16, + line_height: 20, + font_size: 14, + font_weight: Weight::Normal, + class: ButtonClass::Standard, + variant: text, + } } pub fn leading_icon(mut self, icon: impl Into) -> Self { @@ -81,25 +95,21 @@ impl<'a, Message> Button<'a, Message> { impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { fn from(mut builder: Button<'a, Message>) -> Element<'a, Message> { - let trailing_icon = builder.variant.trailing_icon.map(|mut i| { - if let icon::Data::Name(ref mut named) = i.data { - named.size = Some(builder.icon_size); - } + let trailing_icon = builder + .variant + .trailing_icon + .map(crate::widget::icon::Handle::icon); - i.icon() - }); - - let leading_icon = builder.variant.leading_icon.map(|mut i| { - if let icon::Data::Name(ref mut named) = i.data { - named.size = Some(builder.icon_size); - } - - i.icon() - }); + let leading_icon = builder + .variant + .leading_icon + .map(crate::widget::icon::Handle::icon); let label: Option> = (!builder.label.is_empty()).then(|| { - let mut font = crate::font::DEFAULT; - font.weight = builder.font_weight; + let font = crate::font::Font { + weight: builder.font_weight, + ..crate::font::default() + }; // TODO: Avoid allocation crate::widget::text(builder.label.to_string()) @@ -109,7 +119,7 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes .into() }); - let button: super::Button<'a, Message, crate::Renderer> = row::with_capacity(3) + let mut button: super::Button<'a, Message> = row::with_capacity(3) // Optional icon to place before label. .push_maybe(leading_icon) // Optional label between icons. @@ -120,24 +130,36 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes .width(builder.width) .height(builder.height) .spacing(builder.spacing) - .align_items(Alignment::Center) - .apply(button) + .align_y(Alignment::Center) + .apply(super::custom) .padding(0) .id(builder.id) .on_press_maybe(builder.on_press.take()) - .style(builder.style); + .class(builder.class); + + #[cfg(feature = "a11y")] + { + if !builder.label.is_empty() { + button = button.name(builder.label) + } + + button = button.description(builder.description); + } if builder.tooltip.is_empty() { button.into() } else { - tooltip(button, builder.tooltip, tooltip::Position::Top) - .size(builder.font_size) - .font({ - let mut font = crate::font::DEFAULT; - font.weight = builder.font_weight; - font - }) - .into() + tooltip( + button, + crate::widget::text(builder.tooltip) + .size(builder.font_size) + .font(crate::font::Font { + weight: builder.font_weight, + ..crate::font::default() + }), + tooltip::Position::Top, + ) + .into() } } } diff --git a/src/widget/button/widget.rs b/src/widget/button/widget.rs index 59799399..4acf3f2d 100644 --- a/src/widget/button/widget.rs +++ b/src/widget/button/widget.rs @@ -7,30 +7,30 @@ //! A [`Button`] has some local [`State`]. use iced_runtime::core::widget::Id; -use iced_runtime::{keyboard, Command}; +use iced_runtime::{Action, Task, keyboard, task}; use iced_core::event::{self, Event}; -use iced_core::mouse; -use iced_core::overlay; -use iced_core::renderer::{self, Quad}; +use iced_core::renderer::{self, Quad, Renderer}; use iced_core::touch; -use iced_core::widget::tree::{self, Tree}; use iced_core::widget::Operation; -use iced_core::{layout, svg}; +use iced_core::widget::tree::{self, Tree}; use iced_core::{ - Background, Clipboard, Color, Element, Layout, Length, Padding, Point, Rectangle, Shell, - Vector, Widget, + Background, Clipboard, Color, Layout, Length, Padding, Point, Rectangle, Shell, Vector, Widget, }; -use iced_renderer::core::widget::{operation, OperationOutputWrapper}; +use iced_core::{Border, mouse}; +use iced_core::{Shadow, overlay}; +use iced_core::{layout, svg}; +use iced_renderer::core::widget::operation; -pub use super::style::{Appearance, StyleSheet}; +use crate::theme::THEME; + +pub use super::style::{Catalog, Style}; /// Internally defines different button widget variants. enum Variant { Normal, Image { close_icon: svg::Handle, - selection_icon: svg::Handle, on_remove: Option, }, } @@ -38,35 +38,29 @@ enum Variant { /// A generic button which emits a message when pressed. #[allow(missing_debug_implementations)] #[must_use] -pub struct Button<'a, Message, Renderer> -where - Renderer: iced_core::Renderer, - Renderer::Theme: StyleSheet, -{ +pub struct Button<'a, Message> { id: Id, #[cfg(feature = "a11y")] - name: Option>, + name: Option>, #[cfg(feature = "a11y")] description: Option>, #[cfg(feature = "a11y")] label: Option>, - content: Element<'a, Message, Renderer>, - on_press: Option, + content: crate::Element<'a, Message>, + on_press: Option Message + 'a>>, + on_press_down: Option Message + 'a>>, width: Length, height: Length, padding: Padding, selected: bool, - style: ::Style, + style: crate::theme::Button, variant: Variant, + force_enabled: bool, } -impl<'a, Message, Renderer> Button<'a, Message, Renderer> -where - Renderer: iced_core::Renderer, - Renderer::Theme: StyleSheet, -{ +impl<'a, Message: Clone + 'a> Button<'a, Message> { /// Creates a new [`Button`] with the given content. - pub fn new(content: impl Into>) -> Self { + pub(super) fn new(content: impl Into>) -> Self { Self { id: Id::unique(), #[cfg(feature = "a11y")] @@ -77,17 +71,19 @@ where label: None, content: content.into(), on_press: None, + on_press_down: None, width: Length::Shrink, height: Length::Shrink, padding: Padding::new(5.0), selected: false, - style: ::Style::default(), + style: crate::theme::Button::default(), variant: Variant::Normal, + force_enabled: false, } } pub fn new_image( - content: impl Into>, + content: impl Into>, on_remove: Option, ) -> Self { Self { @@ -96,15 +92,17 @@ where name: None, #[cfg(feature = "a11y")] description: None, + force_enabled: false, #[cfg(feature = "a11y")] label: None, content: content.into(), on_press: None, + on_press_down: None, width: Length::Shrink, height: Length::Shrink, padding: Padding::new(5.0), selected: false, - style: ::Style::default(), + style: crate::theme::Button::default(), variant: Variant::Image { on_remove, close_icon: crate::widget::icon::from_name("window-close-symbolic") @@ -115,47 +113,77 @@ where let bytes: &'static [u8] = &[]; iced_core::svg::Handle::from_memory(bytes) }), - selection_icon: crate::widget::icon::from_name("object-select-symbolic") - .size(16) - .icon() - .into_svg_handle() - .unwrap_or_else(|| { - let bytes: &'static [u8] = &[]; - iced_core::svg::Handle::from_memory(bytes) - }), }, } } /// Sets the [`Id`] of the [`Button`]. + #[inline] pub fn id(mut self, id: Id) -> Self { self.id = id; self } /// Sets the width of the [`Button`]. + #[inline] pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } /// Sets the height of the [`Button`]. + #[inline] pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } /// Sets the [`Padding`] of the [`Button`]. + #[inline] pub fn padding>(mut self, padding: P) -> Self { self.padding = padding.into(); self } - /// Sets the message that will be produced when the [`Button`] is pressed. + /// Sets the message that will be produced when the [`Button`] is pressed and released. /// - /// Unless `on_press` is called, the [`Button`] will be disabled. + /// Unless `on_press` or `on_press_down` is called, the [`Button`] will be disabled. + #[inline] pub fn on_press(mut self, on_press: Message) -> Self { - self.on_press = Some(on_press); + self.on_press = Some(Box::new(move |_, _| on_press.clone())); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed and released. + /// + /// Unless `on_press` or `on_press_down` is called, the [`Button`] will be disabled. + #[inline] + pub fn on_press_with_rectangle( + mut self, + on_press: impl Fn(Vector, Rectangle) -> Message + 'a, + ) -> Self { + self.on_press = Some(Box::new(on_press)); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed, + /// + /// Unless `on_press` or `on_press_down` is called, the [`Button`] will be disabled. + #[inline] + pub fn on_press_down(mut self, on_press: Message) -> Self { + self.on_press_down = Some(Box::new(move |_, _| on_press.clone())); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed, + /// + /// Unless `on_press` or `on_press_down` is called, the [`Button`] will be disabled. + #[inline] + pub fn on_press_down_with_rectange( + mut self, + on_press: impl Fn(Vector, Rectangle) -> Message + 'a, + ) -> Self { + self.on_press_down = Some(Box::new(on_press)); self } @@ -163,14 +191,65 @@ where /// if `Some`. /// /// If `None`, the [`Button`] will be disabled. + #[inline] pub fn on_press_maybe(mut self, on_press: Option) -> Self { - self.on_press = on_press; + if let Some(m) = on_press { + self.on_press(m) + } else { + self.on_press = None; + self + } + } + + /// Sets the message that will be produced when the [`Button`] is pressed and released. + /// + /// Unless `on_press` or `on_press_down` is called, the [`Button`] will be disabled. + #[inline] + pub fn on_press_maybe_with_rectangle( + mut self, + on_press: impl Fn(Vector, Rectangle) -> Message + 'a, + ) -> Self { + self.on_press = Some(Box::new(on_press)); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed, + /// if `Some`. + /// + /// If `None`, the [`Button`] will be disabled. + #[inline] + pub fn on_press_down_maybe(mut self, on_press: Option) -> Self { + if let Some(m) = on_press { + self.on_press(m) + } else { + self.on_press_down = None; + self + } + } + + /// Sets the message that will be produced when the [`Button`] is pressed and released. + /// + /// Unless `on_press` or `on_press_down` is called, the [`Button`] will be disabled. + #[inline] + pub fn on_press_down_maybe_with_rectangle( + mut self, + on_press: impl Fn(Vector, Rectangle) -> Message + 'a, + ) -> Self { + self.on_press_down = Some(Box::new(on_press)); + self + } + + /// Sets the the [`Button`] to enabled whether or not it has handlers for on press. + #[inline] + pub fn force_enabled(mut self, enabled: bool) -> Self { + self.force_enabled = enabled; self } /// Sets the widget to a selected state. /// /// Displays a selection indicator on image buttons. + #[inline] pub fn selected(mut self, selected: bool) -> Self { self.selected = selected; @@ -178,14 +257,15 @@ where } /// Sets the style variant of this [`Button`]. - pub fn style(mut self, style: ::Style) -> Self { + #[inline] + pub fn class(mut self, style: crate::theme::Button) -> Self { self.style = style; self } #[cfg(feature = "a11y")] /// Sets the name of the [`Button`]. - pub fn name(mut self, name: impl Into>) -> Self { + pub fn name(mut self, name: impl Into>) -> Self { self.name = Some(name.into()); self } @@ -201,7 +281,7 @@ where #[cfg(feature = "a11y")] /// Sets the description of the [`Button`]. - pub fn description(mut self, description: impl Into>) -> Self { + pub fn description(mut self, description: impl Into>) -> Self { self.description = Some(iced_accessibility::Description::Text(description.into())); self } @@ -214,11 +294,8 @@ where } } -impl<'a, Message, Renderer> Widget for Button<'a, Message, Renderer> -where - Message: 'a + Clone, - Renderer: 'a + iced_core::Renderer + svg::Renderer, - Renderer::Theme: StyleSheet, +impl<'a, Message: 'a + Clone> Widget + for Button<'a, Message> { fn tag(&self) -> tree::Tag { tree::Tag::of::() @@ -236,55 +313,65 @@ where tree.diff_children(std::slice::from_mut(&mut self.content)); } - fn width(&self) -> Length { - self.width + fn size(&self) -> iced_core::Size { + iced_core::Size::new(self.width, self.height) } - fn height(&self) -> Length { - self.height - } - - fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + fn layout( + &mut self, + tree: &mut Tree, + renderer: &crate::Renderer, + limits: &layout::Limits, + ) -> layout::Node { layout( renderer, limits, self.width, self.height, self.padding, - |renderer, limits| self.content.as_widget().layout(renderer, limits), + |renderer, limits| { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + }, ) } fn operate( - &self, + &mut self, tree: &mut Tree, layout: Layout<'_>, - renderer: &Renderer, - operation: &mut dyn Operation>, + renderer: &crate::Renderer, + operation: &mut dyn Operation<()>, ) { - operation.container(None, layout.bounds(), &mut |operation| { - self.content.as_widget().operate( + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( &mut tree.children[0], - layout.children().next().unwrap(), + layout + .children() + .next() + .unwrap() + .with_virtual_offset(layout.virtual_offset()), renderer, operation, ); }); let state = tree.state.downcast_mut::(); - operation.focusable(state, Some(&self.id)); + operation.focusable(Some(&self.id), layout.bounds(), state); } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, - renderer: &Renderer, + renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { + ) { if let Variant::Image { on_remove: Some(on_remove), .. @@ -297,7 +384,8 @@ where if let Some(position) = cursor.position() { if removal_bounds(layout.bounds(), 4.0).contains(position) { shell.publish(on_remove.clone()); - return event::Status::Captured; + shell.capture_event(); + return; } } } @@ -305,19 +393,22 @@ where _ => (), } } - - if self.content.as_widget_mut().on_event( + self.content.as_widget_mut().update( &mut tree.children[0], - event.clone(), - layout.children().next().unwrap(), + event, + layout + .children() + .next() + .unwrap() + .with_virtual_offset(layout.virtual_offset()), cursor, renderer, clipboard, shell, viewport, - ) == event::Status::Captured - { - return event::Status::Captured; + ); + if shell.is_event_captured() { + return; } update( @@ -326,115 +417,186 @@ where layout, cursor, shell, - &self.on_press, + self.on_press.as_deref(), + self.on_press_down.as_deref(), || tree.state.downcast_mut::(), ) } + #[allow(clippy::too_many_lines)] fn draw( &self, tree: &Tree, - renderer: &mut Renderer, - theme: &Renderer::Theme, + renderer: &mut crate::Renderer, + theme: &crate::Theme, renderer_style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, - _viewport: &Rectangle, + viewport: &Rectangle, ) { let bounds = layout.bounds(); - let content_layout = layout.children().next().unwrap(); + if !viewport.intersects(&bounds) { + return; + } - let styling = draw( + // FIXME: Why is there no content layout + let Some(content_layout) = layout.children().next() else { + return; + }; + + let mut headerbar_alpha = None; + + let is_enabled = + self.on_press.is_some() || self.on_press_down.is_some() || self.force_enabled; + let is_mouse_over = cursor.position().is_some_and(|p| bounds.contains(p)); + + let state = tree.state.downcast_ref::(); + + let styling = if !is_enabled { + theme.disabled(&self.style) + } else if is_mouse_over { + if state.is_pressed { + if !self.selected && matches!(self.style, crate::theme::Button::HeaderBar) { + headerbar_alpha = Some(0.8); + } + + theme.pressed(state.is_focused, self.selected, &self.style) + } else { + if !self.selected && matches!(self.style, crate::theme::Button::HeaderBar) { + headerbar_alpha = Some(0.8); + } + theme.hovered(state.is_focused, self.selected, &self.style) + } + } else { + if !self.selected && matches!(self.style, crate::theme::Button::HeaderBar) { + headerbar_alpha = Some(0.75); + } + + theme.active(state.is_focused, self.selected, &self.style) + }; + + let mut icon_color = styling.icon_color.unwrap_or(renderer_style.icon_color); + + // Menu roots should share the accent color that icons get in the header. + let mut text_color = if matches!(self.style, crate::theme::Button::MenuRoot) { + icon_color + } else { + styling.text_color.unwrap_or(renderer_style.text_color) + }; + + if let Some(alpha) = headerbar_alpha { + icon_color.a = alpha; + text_color.a = alpha; + } + + draw::<_, crate::Theme>( renderer, bounds, - cursor, - self.on_press.is_some(), - self.selected, - theme, - &self.style, - || tree.state.downcast_ref::(), - |renderer, styling| { + *viewport, + &styling, + |renderer, _styling| { self.content.as_widget().draw( &tree.children[0], renderer, theme, &renderer::Style { - icon_color: styling.icon_color.unwrap_or(renderer_style.icon_color), - text_color: styling.text_color.unwrap_or(renderer_style.icon_color), + icon_color, + text_color, scale_factor: renderer_style.scale_factor, }, - content_layout, + content_layout.with_virtual_offset(layout.virtual_offset()), cursor, - &bounds, + &viewport.intersection(&bounds).unwrap_or_default(), ); }, + matches!(self.variant, Variant::Image { .. }), ); if let Variant::Image { close_icon, - selection_icon, on_remove, } = &self.variant { - let selection_background = theme.selection_background(); + renderer.with_layer(*viewport, |renderer| { + let selection_background = theme.selection_background(); - if self.selected { - renderer.fill_quad( - Quad { - bounds: Rectangle { - width: 24.0, - height: 20.0, - x: bounds.x + styling.border_width, - y: bounds.y + (bounds.height - 20.0 - styling.border_width), + let c_rad = THEME.lock().unwrap().cosmic().corner_radii; + + if self.selected { + renderer.fill_quad( + Quad { + bounds: Rectangle { + width: 24.0, + height: 20.0, + x: bounds.x + styling.border_width, + y: bounds.y + (bounds.height - 20.0 - styling.border_width), + }, + border: Border { + radius: [ + c_rad.radius_0[0], + c_rad.radius_s[1], + c_rad.radius_0[2], + c_rad.radius_s[3], + ] + .into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, }, - border_radius: [0.0, 8.0, 0.0, 8.0].into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - selection_background, - ); + selection_background, + ); - iced_core::svg::Renderer::draw( - renderer, - selection_icon.clone(), - styling.icon_color, - Rectangle { + let svg_handle = svg::Svg::new(crate::widget::common::object_select().clone()) + .color(icon_color); + let bounds = Rectangle { width: 16.0, height: 16.0, x: bounds.x + 5.0 + styling.border_width, y: bounds.y + (bounds.height - 18.0 - styling.border_width), - }, - ); - } - - if on_remove.is_some() { - if let Some(position) = cursor.position() { - if bounds.contains(position) { - let bounds = removal_bounds(layout.bounds(), 4.0); - renderer.fill_quad( - renderer::Quad { - bounds, - border_radius: 20.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - selection_background, - ); - - iced_core::svg::Renderer::draw( - renderer, - close_icon.clone(), - styling.icon_color, - Rectangle { - width: 16.0, - height: 16.0, - x: bounds.x + 4.0, - y: bounds.y + 4.0, - }, - ); + }; + if bounds.intersects(viewport) { + iced_core::svg::Renderer::draw_svg(renderer, svg_handle, bounds, bounds); } } - } + + if on_remove.is_some() { + if let Some(position) = cursor.position() { + if bounds.contains(position) { + let bounds = removal_bounds(layout.bounds(), 4.0); + renderer.fill_quad( + renderer::Quad { + bounds, + shadow: Shadow::default(), + border: Border { + radius: c_rad.radius_m.into(), + ..Default::default() + }, + snap: true, + }, + selection_background, + ); + let svg_handle = svg::Svg::new(close_icon.clone()).color(icon_color); + iced_core::svg::Renderer::draw_svg( + renderer, + svg_handle, + Rectangle { + width: 16.0, + height: 16.0, + x: bounds.x + 4.0, + y: bounds.y + 4.0, + }, + Rectangle { + width: 16.0, + height: 16.0, + x: bounds.x + 4.0, + y: bounds.y + 4.0, + }, + ); + } + } + } + }); } } @@ -444,21 +606,36 @@ where layout: Layout<'_>, cursor: mouse::Cursor, _viewport: &Rectangle, - _renderer: &Renderer, + _renderer: &crate::Renderer, ) -> mouse::Interaction { - mouse_interaction(layout, cursor, self.on_press.is_some()) + mouse_interaction( + layout.with_virtual_offset(layout.virtual_offset()), + cursor, + self.on_press.is_some(), + ) } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, - renderer: &Renderer, - ) -> Option> { + layout: Layout<'b>, + renderer: &crate::Renderer, + viewport: &Rectangle, + mut translation: Vector, + ) -> Option> { + let position = layout.bounds().position(); + translation.x += position.x; + translation.y += position.y; self.content.as_widget_mut().overlay( &mut tree.children[0], - layout.children().next().unwrap(), + layout + .children() + .next() + .unwrap() + .with_virtual_offset(layout.virtual_offset()), renderer, + viewport, + translation, ) } @@ -471,16 +648,17 @@ where p: mouse::Cursor, ) -> iced_accessibility::A11yTree { use iced_accessibility::{ - accesskit::{Action, DefaultActionVerb, NodeBuilder, NodeId, Rect, Role}, A11yNode, A11yTree, + accesskit::{Action, Node, NodeId, Rect, Role}, }; + // TODO why is state None sometimes? + if matches!(state.state, iced_core::widget::tree::State::None) { + tracing::info!("Button state is missing."); + return A11yTree::default(); + } let child_layout = layout.children().next().unwrap(); - let child_tree = &state.children[0]; - let child_tree = self - .content - .as_widget() - .a11y_nodes(child_layout, &child_tree, p); + let child_tree = state.children.first(); let Rectangle { x, @@ -491,21 +669,16 @@ where let bounds = Rect::new(x as f64, y as f64, (x + width) as f64, (y + height) as f64); let is_hovered = state.state.downcast_ref::().is_hovered; - let mut node = NodeBuilder::new(Role::Button); + let mut node = Node::new(Role::Button); node.add_action(Action::Focus); - node.add_action(Action::Default); + node.add_action(Action::Click); node.set_bounds(bounds); if let Some(name) = self.name.as_ref() { - node.set_name(name.clone()); + node.set_label(name.clone()); } match self.description.as_ref() { Some(iced_accessibility::Description::Id(id)) => { - node.set_described_by( - id.iter() - .cloned() - .map(|id| NodeId::from(id)) - .collect::>(), - ); + node.set_described_by(id.iter().cloned().map(NodeId::from).collect::>()); } Some(iced_accessibility::Description::Text(text)) => { node.set_description(text.clone()); @@ -518,14 +691,24 @@ where } if self.on_press.is_none() { - node.set_disabled() + node.set_disabled(); } - if is_hovered { - node.set_hovered() - } - node.set_default_action_verb(DefaultActionVerb::Click); + // TODO hover + // if is_hovered { + // node.set_hovered(); + // } - A11yTree::node_with_child_tree(A11yNode::new(node, self.id.clone()), child_tree) + if let Some(child_tree) = child_tree.map(|child_tree| { + self.content.as_widget().a11y_nodes( + child_layout.with_virtual_offset(layout.virtual_offset()), + child_tree, + p, + ) + }) { + A11yTree::node_with_child_tree(A11yNode::new(node, self.id.clone()), child_tree) + } else { + A11yTree::leaf(node, self.id.clone()) + } } fn id(&self) -> Option { @@ -537,13 +720,8 @@ where } } -impl<'a, Message, Renderer> From> for Element<'a, Message, Renderer> -where - Message: Clone + 'a, - Renderer: iced_core::Renderer + svg::Renderer + 'a, - Renderer::Theme: StyleSheet, -{ - fn from(button: Button<'a, Message, Renderer>) -> Self { +impl<'a, Message: Clone + 'a> From> for crate::Element<'a, Message> { + fn from(button: Button<'a, Message>) -> Self { Self::new(button) } } @@ -559,26 +737,31 @@ pub struct State { impl State { /// Creates a new [`State`]. + #[inline] pub fn new() -> Self { Self::default() } /// Returns whether the [`Button`] is currently focused or not. + #[inline] pub fn is_focused(self) -> bool { self.is_focused } /// Returns whether the [`Button`] is currently hovered or not. + #[inline] pub fn is_hovered(self) -> bool { self.is_hovered } /// Focuses the [`Button`]. + #[inline] pub fn focus(&mut self) { self.is_focused = true; } /// Unfocuses the [`Button`]. + #[inline] pub fn unfocus(&mut self) { self.is_focused = false; } @@ -586,34 +769,43 @@ impl State { /// Processes the given [`Event`] and updates the [`State`] of a [`Button`] /// accordingly. -#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)] pub fn update<'a, Message: Clone>( _id: Id, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, shell: &mut Shell<'_, Message>, - on_press: &Option, + on_press: Option<&dyn Fn(Vector, Rectangle) -> Message>, + on_press_down: Option<&dyn Fn(Vector, Rectangle) -> Message>, state: impl FnOnce() -> &'a mut State, -) -> event::Status { +) { match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { - if on_press.is_some() { + // Unfocus the button on clicks in case another widget was clicked. + let state = state(); + state.unfocus(); + + if on_press.is_some() || on_press_down.is_some() { let bounds = layout.bounds(); if cursor.is_over(bounds) { - let state = state(); - state.is_pressed = true; - return event::Status::Captured; + if let Some(on_press_down) = on_press_down { + let msg = (on_press_down)(layout.virtual_offset(), layout.bounds()); + shell.publish(msg); + } + + shell.capture_event(); + return; } } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) => { - if let Some(on_press) = on_press.clone() { + if let Some(on_press) = on_press { let state = state(); if state.is_pressed { @@ -622,32 +814,43 @@ pub fn update<'a, Message: Clone>( let bounds = layout.bounds(); if cursor.is_over(bounds) { - shell.publish(on_press); + let msg = (on_press)(layout.virtual_offset(), layout.bounds()); + shell.publish(msg); } - return event::Status::Captured; + shell.capture_event(); + return; } + } else if on_press_down.is_some() { + let state = state(); + state.is_pressed = false; } } #[cfg(feature = "a11y")] Event::A11y(event_id, iced_accessibility::accesskit::ActionRequest { action, .. }) => { let state = state(); - if let Some(Some(on_press)) = (id == event_id - && matches!(action, iced_accessibility::accesskit::Action::Default)) - .then(|| on_press.clone()) + if let Some(on_press) = matches!(action, iced_accessibility::accesskit::Action::Click) + .then_some(on_press) + .flatten() { state.is_pressed = false; - shell.publish(on_press); + let msg = (on_press)(layout.virtual_offset(), layout.bounds()); + + shell.publish(msg); } - return event::Status::Captured; + shell.capture_event(); + return; } - Event::Keyboard(keyboard::Event::KeyPressed { key_code, .. }) => { - if let Some(on_press) = on_press.clone() { + Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => { + if let Some(on_press) = on_press { let state = state(); - if state.is_focused && key_code == keyboard::KeyCode::Enter { + if state.is_focused && *key == keyboard::Key::Named(keyboard::key::Named::Enter) { state.is_pressed = true; - shell.publish(on_press); - return event::Status::Captured; + let msg = (on_press)(layout.virtual_offset(), layout.bounds()); + + shell.publish(msg); + shell.capture_event(); + return; } } } @@ -658,41 +861,19 @@ pub fn update<'a, Message: Clone>( } _ => {} } - - event::Status::Ignored } #[allow(clippy::too_many_arguments)] -pub fn draw<'a, Renderer: iced_core::Renderer>( +pub fn draw( renderer: &mut Renderer, bounds: Rectangle, - cursor: mouse::Cursor, - is_enabled: bool, - is_selected: bool, - style_sheet: &dyn StyleSheet