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 50a62a50..7897eb01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,16 +33,17 @@ jobs: strategy: fail-fast: false matrix: - features: - - "" # for cosmic-comp, don't remove! - - 'winit_debug' - - 'winit_tokio' - - winit - - winit_wgpu - - wayland - - applet - - desktop,smol - - desktop,tokio + 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 @@ -66,7 +67,7 @@ jobs: - name: Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Test features - run: cargo test --no-default-features --features "${{ matrix.features }}" + run: cargo test ${{ matrix.test_args }} -- --test-threads=1 env: RUST_BACKTRACE: full @@ -103,7 +104,7 @@ jobs: run: sudo apt-get update; sudo apt-get install -y libxkbcommon-dev libwayland-dev - name: Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Test example + - name: Check example run: cargo check -p "${{ matrix.examples }}" env: RUST_BACKTRACE: full diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 46d53ad2..3e3a042e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,19 +7,30 @@ on: jobs: pages: - runs-on: ubuntu-latest steps: - - name: Checkout sources - uses: actions/checkout@v3 - with: - submodules: recursive - - name: Build documentation - run: cargo doc --verbose --features tokio,winit - - name: Deploy documentation - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./target/doc - force_orphan: true + - 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 367f7f22..fdaf8abe 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = iced url = https://github.com/pop-os/iced.git branch = master +[submodule "icon-theme"] + path = cosmic-icons + url = https://github.com/pop-os/cosmic-icons diff --git a/Cargo.toml b/Cargo.toml index 8696f881..d73da2dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,50 @@ [package] name = "libcosmic" -version = "0.1.0" -edition = "2021" -rust-version = "1.80" +version = "1.0.0" +edition = "2024" +rust-version = "1.90" [lib] name = "cosmic" [features] -default = ["clipboard"] +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"] -# XXX Use "a11y"; which is causing a panic currently -applet = ["wayland", "tokio", "cosmic-panel-config", "ron"] -applet-token = [] -clipboard = ["iced_sctk?/clipboard"] -# Use the cosmic-settings-daemon for config handling -dbus-config = ["cosmic-config/dbus", "dep:zbus", "cosmic-settings-daemon"] +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 @@ -30,23 +56,22 @@ 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", -] +desktop-systemd-scope = ["desktop", "dep:zbus"] # Enables keycode serialization serde-keycode = ["iced_core/serde"] # Prevents multiple separate process instances. -single-instance = ["dep:zbus", "ron"] +single-instance = ["zbus/blocking-api", "ron"] # smol async runtime -smol = ["dep:smol", "iced/smol", "zbus?/async-io"] +smol = ["dep:smol", "iced/smol", "zbus?/async-io", "rfd?/async-std"] tokio = [ "dep:tokio", "ashpd?/tokio", @@ -57,15 +82,24 @@ tokio = [ ] # Tokio async runtime # Wayland window support -wayland = [ +iced-wayland = [ "ashpd?/wayland", - "iced_runtime/wayland", + "autosize", "iced/wayland", - "iced_sctk", - "cctk", + "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 = ["iced/multi-window"] +multi-window = [] # Render with wgpu wgpu = ["iced/wgpu", "iced_wgpu"] # X11 window support via winit @@ -76,51 +110,95 @@ winit_wgpu = ["winit", "wgpu"] # Enables XDG portal integrations xdg-portal = ["ashpd"] 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" -ashpd = { version = "0.9.1", default-features = false, optional = true } -async-fs = { version = "2.1", optional = true } -cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "c8d3a1c", optional = true } -chrono = "0.4.35" +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" } -cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } -css-color = "0.2.5" -derive_setters = "0.1.5" -fraction = "0.15.3" -image = { version = "0.25.1", optional = true } -lazy_static = "1.4.0" -libc = { version = "0.2.155", optional = true } -mime = { version = "0.3.17", optional = true } -palette = "0.7.3" -rfd = { version = "0.14.0", optional = true } -rustix = { version = "0.38.34", features = [ - "pipe", - "process", +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 } -serde = { version = "1.0.180", features = ["derive"] } -slotmap = "1.0.6" -smol = { version = "2.0.0", optional = true } -thiserror = "1.0.44" -tokio = { version = "1.24.2", optional = true } -tracing = "0.1" -unicode-segmentation = "1.6" -url = "2.4.0" -ustr = { version = "1.0.0", features = ["serde"] } -zbus = { version = "4.2.1", default-features = false, 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.5" -freedesktop-desktop-entry = { version = "0.5.1", optional = true } +# 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" [dependencies.iced] path = "./iced" default-features = false -features = ["advanced", "image", "lazy", "svg", "web-colors"] +features = [ + "advanced", + "image-without-codecs", + "lazy", + "svg", + "web-colors", + "tiny-skia", +] [dependencies.iced_runtime] path = "./iced/runtime" @@ -146,13 +224,6 @@ 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 @@ -163,17 +234,13 @@ 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", @@ -184,8 +251,7 @@ members = [ exclude = ["iced"] [workspace.dependencies] -dirs = "5.0.1" +dirs = "6.0.0" - -[patch."https://github.com/pop-os/libcosmic"] -libcosmic = { path = "./" } +[dev-dependencies] +tempfile = "3.27.0" diff --git a/README.md b/README.md index 429add0c..23da97bc 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ A platform toolkit based on iced for creating applets and applications for the C ## 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 compile a typical COSMIC project: +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 libxkbcommon-dev pkgconf @@ -25,7 +26,7 @@ Some examples are included in the [examples](./examples) directory to to kicksta 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 ``` 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 46d79658..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" -quote = "1.0" \ No newline at end of file +syn = "2.0" +quote = "1.0" diff --git a/cosmic-config-derive/src/lib.rs b/cosmic-config-derive/src/lib.rs index e1ea70fe..cc19a91e 100644 --- a/cosmic-config-derive/src/lib.rs +++ b/cosmic-config-derive/src/lib.rs @@ -17,12 +17,16 @@ fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { let version = attributes .iter() .find_map(|attr| { - if attr.path.is_ident("version") { - match attr.parse_meta() { - Ok(syn::Meta::NameValue(syn::MetaNameValue { - lit: syn::Lit::Int(lit_int), + 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()), + }) => Some(lit_int.base10_parse::().unwrap()), _ => None, } } else { @@ -102,7 +106,7 @@ fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { }) }); - let gen = quote! { + let generate = quote! { impl CosmicConfigEntry for #name { const VERSION: u64 = #version; @@ -143,5 +147,5 @@ fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { } }; - gen.into() + generate.into() } diff --git a/cosmic-config/Cargo.toml b/cosmic-config/Cargo.toml index 98e98dc9..0a7653e0 100644 --- a/cosmic-config/Cargo.toml +++ b/cosmic-config/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cosmic-config" -version = "0.1.0" -edition = "2021" +version = "1.0.0" +edition = "2024" [features] default = ["macro", "subscription"] @@ -11,24 +11,23 @@ subscription = ["iced_futures"] [dependencies] cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } -zbus = { version = "4.2.1", default-features = false, optional = true } +zbus = { version = "5.14.0", default-features = false, optional = true } atomicwrites = { git = "https://github.com/jackpot51/rust-atomicwrites" } -calloop = { version = "0.14.0", optional = true } -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_futures = { path = "../iced/futures/", default-features = false, optional = true } -once_cell = "1.19.0" futures-util = { version = "0.3", optional = true } dirs.workspace = true -tokio = { version = "1.0", optional = true, features = ["time"] } -async-std = { version = "1.10", optional = true } +tokio = { version = "1.50", optional = true, features = ["time"] } +async-std = { version = "1.13", optional = true } tracing = "0.1" [target.'cfg(unix)'.dependencies] -xdg = "2.1" +xdg = "3.0" [target.'cfg(windows)'.dependencies] -known-folders = "1.1.0" +known-folders = "1.4.2" diff --git a/cosmic-config/src/dbus.rs b/cosmic-config/src/dbus.rs index a36d49db..da7bcb68 100644 --- a/cosmic-config/src/dbus.rs +++ b/cosmic-config/src/dbus.rs @@ -1,9 +1,13 @@ -use std::ops::Deref; +use std::{any::TypeId, ops::Deref}; use crate::{CosmicConfigEntry, Update}; use cosmic_settings_daemon::{Changed, ConfigProxy, CosmicSettingsDaemonProxy}; use futures_util::SinkExt; -use iced_futures::futures::{self, future::pending, StreamExt}; +use iced_futures::{ + Subscription, + futures::{self, StreamExt, future::pending}, + stream, +}; pub async fn settings_daemon_proxy() -> zbus::Result> { let conn = zbus::Connection::session().await?; @@ -17,6 +21,7 @@ pub struct Watcher { impl Deref for Watcher { type Target = ConfigProxy<'static>; + #[inline] fn deref(&self) -> &Self::Target { &self.proxy } @@ -52,153 +57,206 @@ impl Watcher { } } +#[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> { - enum Change { - Changes(Changed), - OwnerChanged(bool), - } - let id = std::any::TypeId::of::(); - iced_futures::subscription::channel((is_state, config_id, id), 5, move |mut tx| 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 config = match T::get_entry(&cosmic_config) { - Ok(config) => config, - Err((errors, default)) => { - if !errors.is_empty() { - eprintln!("Error getting config: {config_id} {errors:?}"); - } - default + 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; - if let Err(err) = tx - .send(Update { - errors: Vec::new(), - keys: Vec::new(), - config: config.clone(), - }) - .await - { - eprintln!("Failed to send config: {err}"); - } + 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; + 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(); - 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; + loop { + let watcher = if is_state { + Watcher::new_state(&settings_daemon, config_id, version).await } else { - // The settings daemon has exited - break; - } - }, - }; + Watcher::new_config(&settings_daemon, config_id, version).await + }; + let Ok(watcher) = watcher else { + tracing::error!("Failed to create watcher for {config_id}"); - // 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 - { - eprintln!("Failed to send config update: {err}"); + #[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 48a18a52..c8eda064 100644 --- a/cosmic-config/src/lib.rs +++ b/cosmic-config/src/lib.rs @@ -1,17 +1,59 @@ //! Integrations for cosmic-config — the cosmic configuration system. use notify::{ - event::{EventKind, ModifyKind}, - Watcher, + RecommendedWatcher, Watcher, + event::{EventKind, ModifyKind, RenameMode}, }; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{Serialize, de::DeserializeOwned}; use std::{ - fmt, fs, + 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")] @@ -33,12 +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), @@ -46,6 +90,7 @@ 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), @@ -55,6 +100,16 @@ impl fmt::Display for Error { 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) @@ -87,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 { @@ -115,18 +178,11 @@ fn sanitize_name(name: &str) -> Result<&Path, Error> { } 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") - .map_err(std::io::Error::from)? - .find_data_file(path); + let system_path = xdg::BaseDirectories::with_prefix("cosmic").find_data_file(path); #[cfg(windows)] let system_path = @@ -148,9 +204,7 @@ impl Config { // Search data file, which provides default (e.g. /usr/share) #[cfg(unix)] - let system_path = xdg::BaseDirectories::with_prefix("cosmic") - .map_err(std::io::Error::from)? - .find_data_file(&path); + let system_path = xdg::BaseDirectories::with_prefix("cosmic").find_data_file(&path); #[cfg(windows)] let system_path = @@ -158,11 +212,10 @@ impl Config { .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"); + let mut user_path = get_config_dir().ok_or(Error::NoConfigDirectory)?; + user_path.push("cosmic"); + user_path.push(path); - let user_path = cosmic_user_path.join(path); // Create new configuration directory if not found. fs::create_dir_all(&user_path)?; @@ -178,9 +231,9 @@ impl Config { // Look for [name]/v[version] let path = sanitize_name(name)?.join(format!("v{version}")); - let cosmic_user_path = custom_path.join("cosmic"); - - let user_path = cosmic_user_path.join(path); + 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)?; @@ -201,11 +254,9 @@ impl Config { let path = sanitize_name(name)?.join(format!("v{}", version)); // Get libcosmic user state directory - let cosmic_user_path = dirs::state_dir() - .ok_or(Error::NoConfigDirectory)? - .join("cosmic"); - - let user_path = cosmic_user_path.join(path); + 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)?; @@ -216,7 +267,8 @@ impl Config { } // 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()), @@ -227,7 +279,7 @@ 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 @@ -240,10 +292,12 @@ impl Config { let user_path_clone = user_path.clone(); let mut watcher = notify::recommended_watcher(move |event_res: Result| { - 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; } @@ -276,7 +330,7 @@ impl Config { } } })?; - watcher.watch(user_path, notify::RecursiveMode::NonRecursive)?; + watcher.watch(user_path, notify::RecursiveMode::Recursive)?; Ok(watcher) } @@ -288,6 +342,7 @@ impl Config { 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 Some(user_path) = self.user_path.as_ref() else { return Err(Error::NoConfigDirectory); @@ -300,22 +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 = match key_path { + match self.key_path(key) { Ok(key_path) if key_path.is_file() => { // Load user override - fs::read_to_string(key_path).map_err(|err| Error::GetKey(key.to_string(), err))? + let data = fs::read_to_string(key_path) + .map_err(|err| Error::GetKey(key.to_string(), err))?; + + Ok(ron::from_str(&data)?) } - _ => { - // Load system default - let default_path = self.default_path(key)?; - fs::read_to_string(default_path) - .map_err(|err| Error::GetKey(key.to_string(), err))? - } - }; - let t = ron::from_str(&data)?; - Ok(t) + + _ => 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)?) } } @@ -336,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> { @@ -354,7 +421,7 @@ 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)?; @@ -384,6 +451,7 @@ where ) -> (Vec, Vec<&'static str>); } +#[derive(Debug)] pub struct Update { pub errors: Vec, pub keys: Vec<&'static str>, diff --git a/cosmic-config/src/subscription.rs b/cosmic-config/src/subscription.rs index ef88866c..d16b9b65 100644 --- a/cosmic-config/src/subscription.rs +++ b/cosmic-config/src/subscription.rs @@ -1,5 +1,5 @@ -use iced_futures::futures::SinkExt; -use iced_futures::{futures::channel::mpsc, subscription}; +use iced_futures::futures::{SinkExt, Stream}; +use iced_futures::{futures::channel::mpsc, stream}; use notify::RecommendedWatcher; use std::{borrow::Cow, hash::Hash}; @@ -16,57 +16,68 @@ pub enum ConfigUpdate { Failed, } +#[cold] pub fn config_subscription< - I: 'static + Copy + Send + Sync + Hash, + I: 'static + Hash, T: 'static + Send + Sync + PartialEq + Clone + CosmicConfigEntry, >( id: I, config_id: Cow<'static, str>, config_version: u64, ) -> iced_futures::Subscription> { - subscription::channel(id, 100, move |mut output| { - let config_id = config_id.clone(); - async move { + 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 mut state = ConfigState::Init(config_id, config_version, false); + let config_version = *config_version; + let is_state = *is_state; - loop { - state = start_listening(state, &mut output, id).await; - } - } - }) + 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 + Copy + Send + Sync + Hash, + I: 'static + Hash, T: 'static + Send + Sync + PartialEq + Clone + CosmicConfigEntry, >( id: I, config_id: Cow<'static, str>, config_version: u64, ) -> iced_futures::Subscription> { - subscription::channel(id, 100, move |mut output| { - let config_id = config_id.clone(); - async move { + iced_futures::Subscription::run_with( + (id, config_id, config_version, true), + |(_, config_id, config_version, is_state)| { let config_id = config_id.clone(); - let mut state = ConfigState::Init(config_id, config_version, true); + let config_version = *config_version; + let is_state = *is_state; - loop { - state = start_listening(state, &mut output, id).await; - } - } - }) + 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< - I: Copy, - T: 'static + Send + Sync + PartialEq + Clone + CosmicConfigEntry, ->( +async fn start_listening( state: ConfigState, output: &mut mpsc::Sender>, - id: I, ) -> ConfigState { - use iced_futures::futures::{future::pending, StreamExt}; + use iced_futures::futures::{StreamExt, future::pending}; match state { ConfigState::Init(config_id, version, is_state) => { @@ -97,7 +108,7 @@ async fn start_listening< } Err((errors, t)) => { let update = crate::Update { - errors: errors, + errors, keys: Vec::new(), config: t.clone(), }; 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 0e6c5c20..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 @@ -15,18 +15,25 @@ export = ["serde_json"] no-default = [] [dependencies] -palette = { version = "0.7.3", features = ["serializing"] } +palette = { version = "0.7.6", features = ["serializing"] } almost = "0.2" -serde = { version = "1.0.129", features = ["derive"] } -serde_json = { version = "1.0.64", optional = true, features = [ +serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.149", optional = true, features = [ "preserve_order", ] } -ron = "0.8" -lazy_static = "1.4.0" -csscolorparser = { version = "0.6.2", features = ["serde"] } +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 = "1.0.5" +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/lib.rs b/cosmic-theme/src/lib.rs index c30234b1..5d59ccda 100644 --- a/cosmic-theme/src/lib.rs +++ b/cosmic-theme/src/lib.rs @@ -19,6 +19,6 @@ pub mod composite; pub mod steps; /// 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 6933c996..3852742b 100644 --- a/cosmic-theme/src/model/cosmic_palette.rs +++ b/cosmic-theme/src/model/cosmic_palette.rs @@ -1,15 +1,14 @@ -use lazy_static::lazy_static; use palette::Srgba; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; -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 light palette +pub static LIGHT_PALETTE: LazyLock = + LazyLock::new(|| ron::from_str(include_str!("light.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)] @@ -26,6 +25,7 @@ pub enum CosmicPalette { impl CosmicPalette { /// extract the inner palette + #[inline] pub fn inner(self) -> CosmicPaletteInner { match self { CosmicPalette::Dark(p) => p, @@ -37,6 +37,7 @@ impl CosmicPalette { } impl AsMut for CosmicPalette { + #[inline] fn as_mut(&mut self) -> &mut CosmicPaletteInner { match self { CosmicPalette::Dark(p) => p, @@ -48,6 +49,7 @@ impl AsMut for CosmicPalette { } impl AsRef for CosmicPalette { + #[inline] fn as_ref(&self) -> &CosmicPaletteInner { match self { CosmicPalette::Dark(p) => p, @@ -60,6 +62,7 @@ impl AsRef for CosmicPalette { impl CosmicPalette { /// check if the palette is dark + #[inline] pub fn is_dark(&self) -> bool { match self { CosmicPalette::Dark(_) | CosmicPalette::HighContrastDark(_) => true, @@ -68,6 +71,7 @@ impl CosmicPalette { } /// check if the palette is high_contrast + #[inline] pub fn is_high_contrast(&self) -> bool { match self { CosmicPalette::HighContrastLight(_) | CosmicPalette::HighContrastDark(_) => true, @@ -77,6 +81,7 @@ impl CosmicPalette { } impl Default for CosmicPalette { + #[inline] fn default() -> Self { CosmicPalette::Dark(Default::default()) } @@ -88,23 +93,19 @@ 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: Srgba, - /// red: colors used for various points of emphasis in the UI - pub red: Srgba, - /// green: colors used for various points of emphasis in the UI - pub green: Srgba, - /// yellow: colors used for various points of emphasis in the UI - pub yellow: Srgba, + /// 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 + /// 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 + /// Colors used for three levels of surfaces in the UI. pub gray_2: Srgba, - /// colors used for three levels of surfaces in the UI - pub gray_3: Srgba, /// System Neutrals /// A wider spread of dark colors for more general use. @@ -130,13 +131,24 @@ pub struct CosmicPaletteInner { /// A wider spread of dark colors for more general use. pub neutral_10: Srgba, - // Utility Colors - /// Utility bright green - pub bright_green: Srgba, - /// Utility bright red - pub bright_red: Srgba, - /// Utility bright orange - pub bright_orange: Srgba, + /// 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. @@ -153,29 +165,11 @@ pub struct CosmicPaletteInner { pub ext_pink: Srgba, /// Colors used for themes, app icons, illustrations, and other brand purposes. pub ext_indigo: Srgba, - - /// Potential Accent Color Combos - pub accent_blue: Srgba, - /// Potential Accent Color Combos - pub accent_red: Srgba, - /// Potential Accent Color Combos - pub accent_green: Srgba, - /// Potential Accent Color Combos - pub accent_warm_grey: Srgba, - /// Potential Accent Color Combos - pub accent_orange: Srgba, - /// Potential Accent Color Combos - pub accent_yellow: Srgba, - /// Potential Accent Color Combos - pub accent_purple: Srgba, - /// Potential Accent Color Combos - pub accent_pink: Srgba, - /// Potential Accent Color Combos - pub accent_indigo: Srgba, } impl CosmicPalette { /// name of the palette + #[inline] pub fn name(&self) -> &str { match &self { CosmicPalette::Dark(p) => &p.name, diff --git a/cosmic-theme/src/model/dark.ron b/cosmic-theme/src/model/dark.ron index 604e4427..4453b8bf 100644 --- a/cosmic-theme/src/model/dark.ron +++ b/cosmic-theme/src/model/dark.ron @@ -1 +1 @@ -Dark((name:"cosmic-dark",blue:(red:0.5803922,green:0.92156863,blue:0.92156863,alpha:1.0),red:(red:1.0,green:0.70980394,blue:0.70980394,alpha:1.0),green:(red:0.6745098,green:0.96862745,blue:0.8235294,alpha:1.0),yellow:(red:1.0,green:0.94509804,blue:0.61960787,alpha:1.0),gray_1:(red:0.105882354,green:0.105882354,blue:0.105882354,alpha:1.0),gray_2:(red:0.14901961,green:0.14901961,blue:0.14901961,alpha:1.0),gray_3:(red:0.1882353,green:0.1882353,blue:0.1882353,alpha:1.0),neutral_0:(red:0.0,green:0.0,blue:0.0,alpha:1.0),neutral_1:(red:0.105882354,green:0.105882354,blue:0.105882354,alpha:1.0),neutral_2:(red:0.1882353,green:0.1882353,blue:0.1882353,alpha:1.0),neutral_3:(red:0.2784314,green:0.2784314,blue:0.2784314,alpha:1.0),neutral_4:(red:0.36862746,green:0.36862746,blue:0.36862746,alpha:1.0),neutral_5:(red:0.46666667,green:0.46666667,blue:0.46666667,alpha:1.0),neutral_6:(red:0.5686275,green:0.5686275,blue:0.5686275,alpha:1.0),neutral_7:(red:0.67058825,green:0.67058825,blue:0.67058825,alpha:1.0),neutral_8:(red:0.7764706,green:0.7764706,blue:0.7764706,alpha:1.0),neutral_9:(red:0.8862745,green:0.8862745,blue:0.8862745,alpha:1.0),neutral_10:(red:1.0,green:1.0,blue:1.0,alpha:1.0),bright_green:(red:0.36862746,green:0.85882354,blue:0.54901963,alpha:1.0),bright_red:(red:1.0,green:0.627451,blue:0.5647059,alpha:1.0),bright_orange:(red:1.0,green:0.6392157,blue:0.49019608,alpha:1.0),ext_warm_grey:(red:0.60784316,green:0.5568628,blue:0.5411765,alpha:1.0),ext_orange:(red:1.0,green:0.6784314,blue:0.0,alpha:1.0),ext_yellow:(red:0.99607843,green:0.85882354,blue:0.2509804,alpha:1.0),ext_blue:(red:0.28235295,green:0.7254902,blue:0.78039217,alpha:1.0),ext_purple:(red:0.8117647,green:0.49019608,blue:1.0,alpha:1.0),ext_pink:(red:0.9764706,green:0.22745098,blue:0.5137255,alpha:1.0),ext_indigo:(red:0.24313726,green:0.53333336,blue:1.0,alpha:1.0),accent_blue:(red:0.3882353,green:0.8156863,blue:0.8745098,alpha:1.0),accent_red:(red:0.99215686,green:0.6313726,blue:0.627451,alpha:1.0),accent_green:(red:0.57254905,green:0.8117647,blue:0.6117647,alpha:1.0),accent_warm_grey:(red:0.7921569,green:0.7294118,blue:0.7058824,alpha:1.0),accent_orange:(red:1.0,green:0.6784314,blue:0.0,alpha:1.0),accent_yellow:(red:0.96862745,green:0.8784314,blue:0.38431373,alpha:1.0),accent_purple:(red:0.90588236,green:0.6117647,blue:0.99607843,alpha:1.0),accent_pink:(red:1.0,green:0.6117647,blue:0.69411767,alpha:1.0),accent_indigo:(red:0.6313726,green:0.7529412,blue:0.92156863,alpha:1.0))) \ No newline at end of file +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/derivation.rs b/cosmic-theme/src/model/derivation.rs index 994ee35a..dce653e5 100644 --- a/cosmic-theme/src/model/derivation.rs +++ b/cosmic-theme/src/model/derivation.rs @@ -1,4 +1,4 @@ -use palette::Srgba; +use palette::{Srgba, WithAlpha}; use serde::{Deserialize, Serialize}; use crate::composite::over; @@ -25,10 +25,9 @@ impl Container { base: Srgba, on: Srgba, mut small_widget: Srgba, + is_high_contrast: bool, ) -> Self { - let mut divider_c = on; - divider_c.alpha = 0.2; - + let divider_c = on.with_alpha(if is_high_contrast { 0.5 } else { 0.2 }); small_widget.alpha = 0.25; Self { @@ -76,26 +75,31 @@ pub struct Component { #[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 } + #[inline] /// get @pressed_state_color pub fn pressed_state_color(&self) -> Srgba { self.pressed } + #[inline] /// get @selected_state_color pub fn selected_state_color(&self) -> Srgba { self.selected } + #[inline] /// get @selected_state_text_color pub fn selected_state_text_color(&self) -> Srgba { self.selected_text } + #[inline] /// get @focus_color pub fn focus_color(&self) -> Srgba { self.focus @@ -109,13 +113,11 @@ impl Component { hovered: Srgba, pressed: Srgba, ) -> Self { - let base: Srgba = base; let mut base_50 = base; base_50.alpha *= 0.5; let on_20 = neutral; - let mut on_50: Srgba = on_20; - on_50.alpha = 0.5; + let on_50 = on_20.with_alpha(0.5); Component { base, @@ -145,8 +147,7 @@ impl Component { let mut component = Component::colored_component(base, overlay, accent, hovered, pressed); component.on = on_button; - let mut on_disabled = on_button; - on_disabled.alpha = 0.5; + let on_disabled = on_button.with_alpha(0.5); component.on_disabled = on_disabled; component @@ -166,11 +167,8 @@ impl Component { let mut base_50 = base; base_50.alpha *= 0.5; - let mut on_20 = on_component; - let mut on_50 = on_20; - - on_20.alpha = 0.2; - on_50.alpha = 0.5; + let on_20 = on_component.with_alpha(0.2); + let on_65 = on_20.with_alpha(0.65); let mut disabled_border = border; disabled_border.alpha *= 0.5; @@ -194,10 +192,10 @@ impl Component { }, selected_text: accent, focus: accent, - divider: if is_high_contrast { on_50 } else { on_20 }, + divider: if is_high_contrast { on_65 } else { on_20 }, on: on_component, - disabled: over(base_50, base), - on_disabled: over(on_50, base), + 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 7de84a03..29b3ad65 100644 --- a/cosmic-theme/src/model/light.ron +++ b/cosmic-theme/src/model/light.ron @@ -1 +1 @@ -Light((name:"cosmic-light",blue:(red:0.0,green:0.28627452,blue:0.42745098,alpha:1.0),red:(red:0.627451,green:0.14509805,blue:0.16862746,alpha:1.0),green:(red:0.23137255,green:0.43137255,blue:0.2627451,alpha:1.0),yellow:(red:0.5882353,green:0.40784314,blue:0.0,alpha:1.0),gray_1:(red:0.8666667,green:0.8666667,blue:0.8666667,alpha:1.0),gray_2:(red:0.9098039,green:0.9098039,blue:0.9098039,alpha:1.0),gray_3:(red:0.9529412,green:0.9529412,blue:0.9529412,alpha:1.0),neutral_0:(red:1.0,green:1.0,blue:1.0,alpha:1.0),neutral_1:(red:0.8862745,green:0.8862745,blue:0.8862745,alpha:1.0),neutral_2:(red:0.7764706,green:0.7764706,blue:0.7764706,alpha:1.0),neutral_3:(red:0.67058825,green:0.67058825,blue:0.67058825,alpha:1.0),neutral_4:(red:0.5686275,green:0.5686275,blue:0.5686275,alpha:1.0),neutral_5:(red:0.46666667,green:0.46666667,blue:0.46666667,alpha:1.0),neutral_6:(red:0.36862746,green:0.36862746,blue:0.36862746,alpha:1.0),neutral_7:(red:0.2784314,green:0.2784314,blue:0.2784314,alpha:1.0),neutral_8:(red:0.1882353,green:0.1882353,blue:0.1882353,alpha:1.0),neutral_9:(red:0.105882354,green:0.105882354,blue:0.105882354,alpha:1.0),neutral_10:(red:0.0,green:0.0,blue:0.0,alpha:1.0),bright_green:(red:0.0,green:0.34117648,blue:0.17254902,alpha:1.0),bright_red:(red:0.5372549,green:0.015686275,blue:0.09411765,alpha:1.0),bright_orange:(red:0.4745098,green:0.17254902,blue:0.0,alpha:1.0),ext_warm_grey:(red:0.60784316,green:0.5568628,blue:0.5411765,alpha:1.0),ext_orange:(red:0.9843137,green:0.72156864,blue:0.42352942,alpha:1.0),ext_yellow:(red:0.96862745,green:0.8784314,blue:0.38431373,alpha:1.0),ext_blue:(red:0.41568628,green:0.7921569,blue:0.84705883,alpha:1.0),ext_purple:(red:0.8352941,green:0.54901963,blue:1.0,alpha:1.0),ext_pink:(red:1.0,green:0.6117647,blue:0.8666667,alpha:1.0),ext_indigo:(red:0.58431375,green:0.76862746,blue:0.9882353,alpha:1.0),accent_blue:(red:0.0,green:0.32156864,blue:0.3529412,alpha:1.0),accent_red:(red:0.47058824,green:0.16078432,blue:0.18039216,alpha:1.0),accent_green:(red:0.09411765,green:0.33333334,blue:0.16078432,alpha:1.0),accent_warm_grey:(red:0.33333334,green:0.2784314,blue:0.25882354,alpha:1.0),accent_orange:(red:0.38431373,green:0.2509804,blue:0.0,alpha:1.0),accent_yellow:(red:0.3254902,green:0.28235295,blue:0.0,alpha:1.0),accent_purple:(red:0.40784314,green:0.12941177,blue:0.4862745,alpha:1.0),accent_pink:(red:0.5254902,green:0.015686275,blue:0.22745098,alpha:1.0),accent_indigo:(red:0.18039216,green:0.28627452,blue:0.42745098,alpha:1.0))) \ No newline at end of file +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/mode.rs b/cosmic-theme/src/model/mode.rs index f57c653c..ce166979 100644 --- a/cosmic-theme/src/model/mode.rs +++ b/cosmic-theme/src/model/mode.rs @@ -16,6 +16,7 @@ pub struct ThemeMode { } impl Default for ThemeMode { + #[inline] fn default() -> Self { Self { is_dark: true, @@ -25,15 +26,19 @@ 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") } + #[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) diff --git a/cosmic-theme/src/model/theme.rs b/cosmic-theme/src/model/theme.rs index 192f9756..5db0f32c 100644 --- a/cosmic-theme/src/model/theme.rs +++ b/cosmic-theme/src/model/theme.rs @@ -1,11 +1,13 @@ use crate::{ + Component, Container, CornerRadii, CosmicPalette, CosmicPaletteInner, DARK_PALETTE, + LIGHT_PALETTE, NAME, Spacing, ThemeMode, composite::over, - steps::{color_index, get_surface_color, get_text, steps}, - Component, Container, CornerRadii, CosmicPalette, CosmicPaletteInner, Spacing, ThemeMode, - DARK_PALETTE, LIGHT_PALETTE, NAME, + steps::{color_index, get_small_widget_color, get_surface_color, get_text, steps}, }; use cosmic_config::{Config, CosmicConfigEntry}; -use palette::{rgb::Rgb, IntoColor, Oklcha, Srgb, Srgba}; +use palette::{ + IntoColor, Oklcha, Srgb, Srgba, WithAlpha, color_difference::Wcag21RelativeContrast, rgb::Rgb, +}; use serde::{Deserialize, Serialize}; use std::num::NonZeroUsize; @@ -97,9 +99,17 @@ pub struct Theme { 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 Default for Theme { + #[inline] fn default() -> Self { Self::preferred_theme() } @@ -118,44 +128,155 @@ impl Theme { NAME } + #[inline] /// Get the config for the current dark theme pub fn dark_config() -> Result { 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) } + #[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 @@ -163,6 +284,7 @@ impl Theme { #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @success_color pub fn success_color(&self) -> Srgba { self.success.base @@ -170,6 +292,7 @@ impl Theme { #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @destructive_color pub fn destructive_color(&self) -> Srgba { self.destructive.base @@ -177,6 +300,7 @@ impl Theme { #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @warning_color pub fn warning_color(&self) -> Srgba { self.warning.base @@ -184,52 +308,64 @@ impl Theme { #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @small_widget_divider pub fn small_widget_divider(&self) -> Srgba { - let mut neutral_9 = self.palette.neutral_9; - neutral_9.alpha = 0.2; - neutral_9 + 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @bg_component_color pub fn bg_component_color(&self) -> Srgba { 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @primary_component_color pub fn primary_component_color(&self) -> Srgba { 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @secondary_component_color pub fn secondary_component_color(&self) -> Srgba { 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 @@ -238,90 +374,119 @@ impl Theme { // Text #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @on_bg_color pub fn on_bg_color(&self) -> Srgba { 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @on_primary_color pub fn on_primary_container_color(&self) -> Srgba { 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @on_secondary_color pub fn on_secondary_container_color(&self) -> Srgba { 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @accent_text_color pub fn accent_text_color(&self) -> Srgba { - self.accent.base + 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @warning_text_color pub fn warning_text_color(&self) -> Srgba { self.warning.base } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @destructive_text_color pub fn destructive_text_color(&self) -> Srgba { self.destructive.base } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @on_accent_color pub fn on_accent_color(&self) -> Srgba { self.accent.on } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @on_success_color pub fn on_success_color(&self) -> Srgba { self.success.on } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @on_warning_color pub fn on_warning_color(&self) -> Srgba { self.warning.on } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @on_destructive_color pub fn on_destructive_color(&self) -> Srgba { self.destructive.on } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @button_color pub fn button_color(&self) -> Srgba { self.button.on @@ -330,36 +495,47 @@ impl Theme { // Borders and Dividers #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @bg_divider pub fn bg_divider(&self) -> Srgba { 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @primary_container_divider pub fn primary_container_divider(&self) -> Srgba { 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 } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @secondary_container_divider pub fn secondary_container_divider(&self) -> Srgba { self.secondary.divider } + #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @button_divider pub fn button_divider(&self) -> Srgba { self.button.divider @@ -367,6 +543,7 @@ impl Theme { #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @window_header_bg pub fn window_header_bg(&self) -> Srgba { self.background.base @@ -374,60 +551,79 @@ impl Theme { #[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 @@ -435,36 +631,47 @@ impl Theme { #[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 @@ -472,23 +679,23 @@ impl Theme { #[must_use] #[allow(clippy::doc_markdown)] + #[inline] /// get @shade_color pub fn shade_color(&self) -> Srgba { self.shade } - /// get the active theme + /// 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() - } else { - Self::light_config() - } - .map_err(|e| (vec![e], Self::default()))?; - Self::get_entry(&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] @@ -606,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(), @@ -628,6 +835,7 @@ impl Default for ThemeBuilder { } impl ThemeBuilder { + #[inline] /// Get a builder that is initialized with the default dark theme pub fn dark() -> Self { Self { @@ -636,6 +844,7 @@ impl ThemeBuilder { } } + #[inline] /// Get a builder that is initialized with the default light theme pub fn light() -> Self { Self { @@ -644,6 +853,7 @@ impl ThemeBuilder { } } + #[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(); @@ -653,6 +863,7 @@ impl ThemeBuilder { } } + #[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(); @@ -662,6 +873,7 @@ impl ThemeBuilder { } } + #[inline] /// Get a builder that is initialized with the provided palette pub fn palette(palette: CosmicPalette) -> Self { Self { @@ -670,60 +882,70 @@ impl ThemeBuilder { } } + #[inline] /// set the spacing of the builder pub fn spacing(mut self, spacing: Spacing) -> Self { self.spacing = spacing; self } + #[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); @@ -734,7 +956,7 @@ impl ThemeBuilder { /// build the theme pub fn build(self) -> Theme { let Self { - mut palette, + palette, spacing, corner_radii, neutral_tint, @@ -758,47 +980,36 @@ impl ThemeBuilder { let accent = if let Some(accent) = accent { accent.into_color() } else { - palette.as_ref().blue + palette.as_ref().accent_blue }; let success = if let Some(success) = success { success.into_color() } else { - palette.as_ref().green + palette.as_ref().bright_green }; let warning = if let Some(warning) = warning { warning.into_color() } else { - palette.as_ref().yellow + palette.as_ref().bright_orange }; let destructive = if let Some(destructive) = destructive { destructive.into_color() } else { - palette.as_ref().red + 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(); @@ -818,9 +1029,9 @@ impl ThemeBuilder { let bg_index = color_index(bg, step_array.len()); let mut component_hovered_overlay = if bg_index < 91 { - p_ref.neutral_10 + control_steps_array[10] } else { - p_ref.neutral_0 + control_steps_array[0] }; component_hovered_overlay.alpha = 0.1; @@ -828,25 +1039,114 @@ impl ThemeBuilder { component_pressed_overlay.alpha = 0.2; // Standard button background is neutral 7 with 25% opacity - let button_bg = { - let mut color = p_ref.neutral_7; - color.alpha = 0.25; - color - }; + let button_bg = control_steps_array[7].with_alpha(0.25); - 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 (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, - &p_ref.neutral_8, - text_steps_array.as_ref(), + &control_steps_array[8], + text_steps_array.as_deref(), ); + 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 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]); + + 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( + 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 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 + }; + + (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(), shade: if palette.is_dark() { @@ -862,92 +1162,35 @@ impl ThemeBuilder { component_hovered_overlay, component_pressed_overlay, is_high_contrast, - p_ref.neutral_8, + control_steps_array[8], ), bg, get_text( bg_index, &step_array, - &p_ref.neutral_8, - text_steps_array.as_ref(), - ), - get_surface_color( - bg_index, - 5, - &neutral_steps, - bg_index <= 65, - &p_ref.neutral_6, + &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: { - 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, &p_ref.neutral_1) - }; - - let base_index: usize = color_index(container_bg, step_array.len()); - let component_base = - get_surface_color(base_index, 6, &step_array, is_dark, &p_ref.neutral_3); - - component_hovered_overlay = if base_index < 91 { - p_ref.neutral_10 - } else { - p_ref.neutral_0 - }; - component_hovered_overlay.alpha = 0.1; - - component_pressed_overlay = component_hovered_overlay; - component_pressed_overlay.alpha = 0.2; - - let container = Container::new( - Component::component( - component_base, - accent, - get_text( - color_index(component_base, step_array.len()), - &step_array, - &p_ref.neutral_8, - text_steps_array.as_ref(), - ), - component_hovered_overlay, - component_pressed_overlay, - is_high_contrast, - p_ref.neutral_8, - ), - container_bg, - get_text( - base_index, - &step_array, - &p_ref.neutral_8, - text_steps_array.as_ref(), - ), - get_surface_color( - base_index, - 5, - &neutral_steps, - base_index <= 65, - &p_ref.neutral_6, - ), - ); - - container - }, + 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, &p_ref.neutral_2) + 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, &p_ref.neutral_4); + get_surface_color(base_index, 3, &step_array, is_dark, &control_steps_array[4]); component_hovered_overlay = if base_index < 91 { - p_ref.neutral_10 + control_steps_array[10] } else { - p_ref.neutral_0 + control_steps_array[0] }; component_hovered_overlay.alpha = 0.1; @@ -961,41 +1204,36 @@ impl ThemeBuilder { get_text( color_index(secondary_component, step_array.len()), &step_array, - &p_ref.neutral_8, - text_steps_array.as_ref(), + &control_steps_array[8], + text_steps_array.as_deref(), ), component_hovered_overlay, component_pressed_overlay, is_high_contrast, - p_ref.neutral_8, + control_steps_array[8], ), container_bg, get_text( base_index, &step_array, - &p_ref.neutral_8, - text_steps_array.as_ref(), - ), - get_surface_color( - base_index, - 5, - &neutral_steps, - base_index <= 65, - &p_ref.neutral_6, + &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, @@ -1007,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, @@ -1027,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; - 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, @@ -1068,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, @@ -1098,17 +1333,22 @@ 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) } + #[inline] /// Get the builder for the light config pub fn light_config() -> Result { 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 21aab991..40eba5b4 100644 --- a/cosmic-theme/src/output/gtk4_output.rs +++ b/cosmic-theme/src/output/gtk4_output.rs @@ -1,15 +1,17 @@ -use crate::{composite::over, steps::steps, Component, Theme}; -use palette::{rgb::Rgba, Darken, IntoColor, Lighten, Srgba}; +use crate::{Component, Theme, composite::over, steps::steps}; +use palette::{Darken, IntoColor, Lighten, Srgba, WithAlpha, rgb::Rgba}; use std::{ fs::{self, File}, - io::Write, + io::{self, Write}, num::NonZeroUsize, + path::Path, }; -use super::{to_rgba, OutputError}; +use super::{OutputError, to_rgba}; impl Theme { #[must_use] + #[cold] /// turn the theme into css pub fn as_gtk4(&self) -> String { let Self { @@ -73,11 +75,10 @@ impl Theme { Rgba::new(0.0, 0.0, 0.0, 0.08) }); - let mut inverted_bg_divider = background.base; - inverted_bg_divider.alpha = 0.5; + let inverted_bg_divider = background.base.with_alpha(0.5); let scrollbar_outline = to_rgba(inverted_bg_divider); - let mut css = format! {r#" + let mut css = format! {r#"/* GENERATED BY COSMIC */ @define-color window_bg_color {window_bg}; @define-color window_fg_color {window_fg}; @@ -122,10 +123,10 @@ impl Theme { css.push_str(&component_gtk4_css("accent", accent)); css.push_str(&component_gtk4_css("error", destructive)); - css.push_str(&color_css("blue", palette.blue)); - css.push_str(&color_css("green", palette.green)); - css.push_str(&color_css("yellow", palette.yellow)); - css.push_str(&color_css("red", palette.red)); + 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()); @@ -144,9 +145,10 @@ impl Theme { /// # 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(config_dir) = dirs::config_dir() else { + let Some(mut config_dir) = dirs::config_dir() else { return Err(OutputError::MissingConfigDir); }; @@ -156,55 +158,58 @@ impl Theme { "light.css" }; - let config_dir = config_dir.join("gtk-4.0").join("cosmic"); + config_dir.extend(["gtk-4.0", "cosmic"]); if !config_dir.exists() { std::fs::create_dir_all(&config_dir).map_err(OutputError::Io)?; } - let mut file = File::create(config_dir.join(name)).map_err(OutputError::Io)?; - file.write_all(css_str.as_bytes()) - .map_err(OutputError::Io)?; + 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(()) } /// 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 gtk4 = config_dir.join("gtk-4.0"); - let gtk3 = config_dir.join("gtk-3.0"); + 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 = gtk4 - .join("cosmic") - .join(if is_dark { "dark.css" } else { "light.css" }); + let cosmic_css_dir = gtk4.join("cosmic"); + let cosmic_css = cosmic_css_dir.join(if is_dark { "dark.css" } else { "light.css" }); - let gtk4_dest = gtk4.join("gtk.css"); - let gtk3_dest = gtk3.join("gtk.css"); + gtk4.push("gtk.css"); + gtk3.push("gtk.css"); #[cfg(target_family = "unix")] - for gtk_dest in [>k4_dest, >k3_dest] { - use std::fs::metadata; + for gtk_dest in [>k4, >k3] { use std::os::unix::fs::symlink; - - let mut gtk_dest_bak = gtk_dest.clone(); - gtk_dest_bak.set_extension("css.bak"); + Self::backup_non_cosmic_css(gtk_dest, &cosmic_css_dir).map_err(OutputError::Io)?; if gtk_dest.exists() { - if metadata(>k_dest) - .map_err(OutputError::Io)? - .file_type() - .is_symlink() - { - fs::remove_file(>k_dest).map_err(OutputError::Io)?; - } else { - fs::rename(>k_dest, gtk_dest_bak).map_err(OutputError::Io)?; - } + fs::remove_file(gtk_dest).map_err(OutputError::Io)?; } symlink(&cosmic_css, gtk_dest).map_err(OutputError::Io)?; @@ -213,6 +218,11 @@ impl Theme { } /// 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); @@ -221,11 +231,49 @@ impl Theme { 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 = fs::remove_file(gtk3_dest); - fs::remove_file(gtk4_dest).map_err(OutputError::Io)?; - Ok(res.map_err(OutputError::Io)?) + 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)) } } diff --git a/cosmic-theme/src/output/mod.rs b/cosmic-theme/src/output/mod.rs index f2822333..19f7bc5b 100644 --- a/cosmic-theme/src/output/mod.rs +++ b/cosmic-theme/src/output/mod.rs @@ -1,4 +1,5 @@ -use palette::{rgb::Rgba, Srgba}; +use configparser::ini::WriteOptions; +use palette::{Srgba, rgb::Rgba}; use thiserror::Error; use crate::Theme; @@ -6,6 +7,11 @@ use crate::Theme; /// Module for outputting the Cosmic gtk4 theme type as CSS pub mod gtk4_output; +/// 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)] @@ -14,30 +20,48 @@ pub enum OutputError { 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 vs_res = self.clone().apply_vs_code(); + let qt_res = Theme::apply_qt(self.is_dark); + let qt56ct_res = Theme::apply_qt56ct(self.is_dark); gtk_res?; - vs_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 vs_res = Theme::reset_vs_code(); + let qt_res = Theme::reset_qt(); + let qt56ct_res = Theme::reset_qt56ct(); gtk_res?; - vs_res?; + qt_res?; + qt56ct_res?; Ok(()) } } @@ -57,3 +81,9 @@ pub fn to_rgba(c: Srgba) -> String { 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 index 11403dbe..43c36bb6 100644 --- a/cosmic-theme/src/output/vs_code.rs +++ b/cosmic-theme/src/output/vs_code.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::Theme; -use super::{to_hex, OutputError}; +use super::{OutputError, to_hex}; /// Represents the workbench.colorCustomizations section of a VS Code settings.json file #[derive(Debug, Clone, Serialize, Deserialize)] @@ -266,10 +266,12 @@ impl From for VsTheme { } impl Theme { + #[cold] pub fn apply_vs_code(self) -> Result<(), OutputError> { let vs_theme = VsTheme::from(self); - let config_dir = dirs::config_dir().ok_or(OutputError::MissingConfigDir)?; - let vs_code_dir = config_dir.join("Code").join("User"); + 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)?; } @@ -289,10 +291,11 @@ impl Theme { Ok(()) } + #[cold] pub fn reset_vs_code() -> Result<(), OutputError> { - let config_dir = dirs::config_dir().ok_or(OutputError::MissingConfigDir)?; - let vs_code_dir = config_dir.join("Code").join("User"); - let settings_file = vs_code_dir.join("settings.json"); + 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(); diff --git a/cosmic-theme/src/steps.rs b/cosmic-theme/src/steps.rs index 2c306e3c..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,7 +35,7 @@ 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 { @@ -48,12 +48,38 @@ pub fn get_surface_color( .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 text color given a base background color pub fn get_text( base_index: usize, - step_array: &Vec, + 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 { @@ -67,7 +93,7 @@ pub fn get_text( 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_else(|| if is_dark { 99 } else { 0 }); + .unwrap_or(if is_dark { 99 } else { 0 }); *step_array.get(index).unwrap_or(fallback) } @@ -119,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}; @@ -147,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/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 c1706c65..22903eac 100644 --- a/examples/applet/src/window.rs +++ b/examples/applet/src/window.rs @@ -1,26 +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, 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 { @@ -37,71 +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 { - let new_id = Id::unique(); - self.popup.replace(new_id); - let mut popup_settings = - self.core - .applet - .get_popup_settings(Id::MAIN, 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 7f188b27..7a6083e0 100644 --- a/examples/application/Cargo.toml +++ b/examples/application/Cargo.toml @@ -3,12 +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" -tracing-log = "0.2.0" +env_logger = "0.11" [dependencies.libcosmic] path = "../../" -default-features = false -features = ["debug", "winit", "tokio", "xdg-portal", "dbus-config", "a11y"] +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 0eba0a86..f6e571e0 100644 --- a/examples/application/src/main.rs +++ b/examples/application/src/main.rs @@ -3,10 +3,15 @@ //! Application API example -use cosmic::app::{Command, Core, Settings}; -use cosmic::iced_core::Size; +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 { @@ -27,11 +32,31 @@ 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> { - tracing_subscriber::fmt::init(); - let _ = tracing_log::LogTracer::init(); + + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + let input = vec![ (Page::Page1, "🖖 Hello from libcosmic.".into()), @@ -42,20 +67,33 @@ fn main() -> Result<(), Box> { let settings = Settings::default() .size(Size::new(1024., 768.)); - - cosmic::app::run::(settings, input)?; - + 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. @@ -80,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 { @@ -90,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(); @@ -103,33 +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_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 @@ -142,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 index 8eadab14..b7286825 100644 --- a/examples/calendar/Cargo.toml +++ b/examples/calendar/Cargo.toml @@ -1,14 +1,13 @@ [package] name = "calendar" -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 [dependencies] -chrono = "0.4.35" +jiff = "0.2" [dependencies.libcosmic] path = "../../" -default-features = false -features = ["debug", "winit", "tokio", "xdg-portal", "wgpu"] \ No newline at end of file +features = ["debug", "winit", "tokio", "xdg-portal", "wgpu"] diff --git a/examples/calendar/src/main.rs b/examples/calendar/src/main.rs index 1b20f356..494087d1 100644 --- a/examples/calendar/src/main.rs +++ b/examples/calendar/src/main.rs @@ -3,9 +3,10 @@ //! Calendar widget example -use chrono::{Local, NaiveDate}; -use cosmic::app::{Command, Core, Settings}; -use cosmic::{executor, iced, ApplicationExt, Element}; +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] @@ -18,13 +19,15 @@ fn main() -> Result<(), Box> { /// Messages that are used specifically by our [`App`]. #[derive(Clone, Debug)] pub enum Message { - DateSelected(NaiveDate), + DateSelected(Date), + PrevMonth, + NextMonth, } /// The [`App`] stores application-specific state. pub struct App { core: Core, - date_selected: NaiveDate, + calendar_model: CalendarModel, } /// Implement [`cosmic::Application`] to integrate with COSMIC. @@ -49,13 +52,11 @@ 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) { - let now = Local::now(); - + /// Creates the application, and optionally emits task on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Task) { let mut app = App { core, - date_selected: NaiveDate::from(now.naive_local()), + calendar_model: CalendarModel::now(), }; let command = app.update_title(); @@ -64,32 +65,39 @@ 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::DateSelected(date) => { - self.date_selected = 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.date_selected); + println!("Date selected: {:?}", &self.calendar_model.selected); - 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 calendar = cosmic::widget::calendar( + &self.calendar_model, + |date| Message::DateSelected(date), + || Message::PrevMonth, + || Message::NextMonth, + Weekday::Sunday, + ); - let calendar = - cosmic::widget::calendar(&self.date_selected, |date| Message::DateSelected(date)); - - content = content.push(calendar); - - let centered = cosmic::widget::container(content) + let centered = cosmic::widget::container(calendar) .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) } @@ -99,8 +107,11 @@ impl App where Self: cosmic::Application, { - fn update_title(&mut self) -> Command { + 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.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 index 88a491e0..39c550f4 100644 --- a/examples/context-menu/Cargo.toml +++ b/examples/context-menu/Cargo.toml @@ -4,11 +4,18 @@ 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" tracing-log = "0.2.0" [dependencies.libcosmic] path = "../../" -default-features = false -features = ["debug", "winit", "tokio", "xdg-portal"] +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 index cfc99184..e5ca5878 100644 --- a/examples/context-menu/src/main.rs +++ b/examples/context-menu/src/main.rs @@ -3,9 +3,9 @@ //! Application API example -use cosmic::app::{Command, Core, Settings}; -use cosmic::iced_core::Size; -use cosmic::widget::{menu, segmented_button}; +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::Size; +use cosmic::widget::menu; use cosmic::{executor, iced, ApplicationExt, Element}; use std::collections::HashMap; @@ -27,9 +27,8 @@ fn main() -> Result<(), Box> { #[derive(Clone, Debug)] pub enum Message { Clicked, - ShowContext, WindowClose, - ShowWindowMenu, + Surface(cosmic::surface::Action), ToggleHideContent, WindowNew, } @@ -38,7 +37,6 @@ pub enum Message { pub struct App { core: Core, button_label: String, - show_context: bool, hide_content: bool, } @@ -64,40 +62,56 @@ 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, button_label: String::from("Right click me"), hide_content: false, - show_context: false, }; app.set_header_title("COSMIC Context Menu Demo".into()); - let command = app.set_window_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) -> Command { - self.button_label = format!("Clicked {message:?}"); + 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 => {} + } - Command::none() + Task::none() } /// Creates a view after each update. - fn view(&self) -> Element { + fn view(&self) -> Element<'_, Self::Message> { let widget = cosmic::widget::context_menu( - cosmic::widget::button::text(&self.button_label).on_press(Message::Clicked), + 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::Horizontal::Center) - .align_y(iced::alignment::Vertical::Center); + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); Element::from(centered) } @@ -108,18 +122,19 @@ impl App { Some(menu::items( &HashMap::new(), vec![ - menu::Item::Button("New window", ContextMenuAction::WindowNew), + 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", ContextMenuAction::WindowClose), + menu::Item::Button("Quit", None, ContextMenuAction::WindowClose), ], )) } diff --git a/examples/cosmic/Cargo.toml b/examples/cosmic/Cargo.toml index 194ce885..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.14.0" -libcosmic = { path = "../..", features = ["debug", "winit", "tokio", "single-instance", "dbus-config", "a11y", "wgpu", "xdg-portal"] } -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"] +features = ["once_cell"] diff --git a/examples/cosmic/src/window.rs b/examples/cosmic/src/window.rs index b4785340..9fce8767 100644 --- a/examples/cosmic/src/window.rs +++ b/examples/cosmic/src/window.rs @@ -6,7 +6,7 @@ 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}, @@ -17,7 +17,7 @@ use cosmic::{ 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, @@ -231,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 { @@ -253,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() @@ -272,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!( @@ -281,12 +278,14 @@ 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) @@ -324,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) @@ -361,10 +360,7 @@ impl Application for Window { fn subscription(&self) -> Subscription { let window_break = listen_raw(|event, _| match event { - cosmic::iced::Event::Window( - _window_id, - window::Event::Resized { width, height: _ }, - ) => { + 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 @@ -389,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() { @@ -437,10 +433,10 @@ impl Application for Window { Message::ToggleNavBarCondensed => { self.nav_bar_toggled_condensed = !self.nav_bar_toggled_condensed } - Message::Drag => return drag(window::Id::MAIN), - Message::Close => return close(window::Id::MAIN), - Message::Minimize => return minimize(window::Id::MAIN, true), - Message::Maximize => return toggle_maximize(window::Id::MAIN), + 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 => {} @@ -564,12 +560,9 @@ impl Application for Window { }; widgets.push( - scrollable( - container(content.debug(self.debug)) - .align_x(iced::alignment::Horizontal::Center), - ) - .width(Length::Fill) - .into(), + scrollable(container(content.debug(self.debug)).align_x(iced::Alignment::Center)) + .width(Length::Fill) + .into(), ); } @@ -587,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 f0097937..0d31fa93 100644 --- a/examples/cosmic/src/window/demo.rs +++ b/examples/cosmic/src/window/demo.rs @@ -258,12 +258,13 @@ impl State { 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", @@ -276,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)", @@ -299,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 {}:{})", @@ -354,8 +354,7 @@ impl State { .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![ @@ -424,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) @@ -482,7 +480,7 @@ impl State { )) .layer(cosmic::cosmic_theme::Layer::Secondary) .padding(16) - .style(cosmic::theme::Container::Background) + .class(cosmic::theme::Container::Background) .into(), cosmic::widget::text_input::secure_input( "Type to search apps or type “?” for more options...", diff --git a/examples/cosmic/src/window/desktop.rs b/examples/cosmic/src/window/desktop.rs index 4fa726d8..46a4e5b8 100644 --- a/examples/cosmic/src/window/desktop.rs +++ b/examples/cosmic/src/window/desktop.rs @@ -147,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( @@ -155,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(), ]) @@ -245,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(), @@ -261,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), @@ -271,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/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/image-button/Cargo.toml b/examples/image-button/Cargo.toml index cb3ac811..c219a53b 100644 --- a/examples/image-button/Cargo.toml +++ b/examples/image-button/Cargo.toml @@ -4,10 +4,9 @@ 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", "winit", "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 index 44ece16e..430b26ea 100644 --- a/examples/menu/Cargo.toml +++ b/examples/menu/Cargo.toml @@ -4,11 +4,10 @@ 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" tracing-log = "0.2.0" [dependencies.libcosmic] path = "../../" -default-features = false -features = ["debug", "winit", "tokio", "xdg-portal"] +features = ["debug", "winit", "tokio", "xdg-portal", "wgpu"] diff --git a/examples/menu/src/main.rs b/examples/menu/src/main.rs index ab668cb6..da0c3231 100644 --- a/examples/menu/src/main.rs +++ b/examples/menu/src/main.rs @@ -6,15 +6,16 @@ use std::collections::HashMap; use std::{env, process}; -use cosmic::app::{Command, Core, Settings}; +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::alignment::{Horizontal, Vertical}; +use cosmic::iced::keyboard::Key; use cosmic::iced::window; -use cosmic::iced_core::alignment::{Horizontal, Vertical}; -use cosmic::iced_core::keyboard::Key; -use cosmic::iced_core::{Length, Size}; +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 @@ -96,8 +97,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 app = App { core, config: Config { @@ -106,18 +107,18 @@ impl cosmic::Application for App { key_binds: key_binds(), }; - (app, Command::none()) + (app, Task::none()) } - fn header_start(&self) -> Vec> { + fn header_start(&self) -> Vec> { vec![menu_bar(&self.config, &self.key_binds)] } /// Handle application events here. - fn update(&mut self, message: Self::Message) -> Command { + fn update(&mut self, message: Self::Message) -> Task { match message { Message::WindowClose => { - return window::close(window::Id::MAIN); + return window::close(self.core.main_window_id().unwrap()); } Message::WindowNew => match env::current_exe() { Ok(exe) => match process::Command::new(&exe).spawn() { @@ -132,11 +133,11 @@ impl cosmic::Application for App { }, Message::ToggleHideContent => self.config.hide_content = !self.config.hide_content, } - Command::none() + Task::none() } /// Creates a view after each update. - fn view(&self) -> Element { + fn view(&self) -> Element<'_, Self::Message> { let text = if self.config.hide_content { cosmic::widget::text("") } else { @@ -155,22 +156,31 @@ impl cosmic::Application for App { pub fn menu_bar<'a>(config: &Config, key_binds: &HashMap) -> Element<'a, Message> { menu::bar(vec![menu::Tree::with_children( - menu::root("File"), + RcElementWrapper::new(Element::from(menu::root("File"))), menu::items( key_binds, vec![ - menu::Item::Button("New window", Action::WindowNew), + 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", Action::WindowClose), + menu::Item::Button( + "Quit", + Some(cosmic::widget::icon::from_name("window-close-symbolic").into()), + Action::WindowClose, + ), ], ), )]) diff --git a/examples/multi-window/Cargo.toml b/examples/multi-window/Cargo.toml index 7a8e3051..0b5440f8 100644 --- a/examples/multi-window/Cargo.toml +++ b/examples/multi-window/Cargo.toml @@ -6,4 +6,4 @@ 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", "multi-window", "dbus-config", "wgpu"] } +libcosmic = { path = "../..", features = ["debug", "winit", "tokio", "single-instance", "wgpu", "wayland"] } diff --git a/examples/multi-window/src/window.rs b/examples/multi-window/src/window.rs index 3fb843b6..754a0d86 100644 --- a/examples/multi-window/src/window.rs +++ b/examples/multi-window/src/window.rs @@ -2,11 +2,11 @@ use std::collections::HashMap; use cosmic::{ app::Core, - iced::{self, event, window}, - iced_core::{id, Alignment, Length, Point}, - iced_widget::{column, container, scrollable, text, text_input}, + iced::core::{id, Alignment, Length, Point}, + iced::widget::{column, container, scrollable, text}, + iced::{self, event, window, Subscription}, + prelude::*, widget::{button, header_bar}, - ApplicationExt, Command, }; #[derive(Debug, Clone, PartialEq)] @@ -42,10 +42,10 @@ impl cosmic::Application for MultiWindow { &mut self.core } - fn init(core: Core, _input: Self::Flags) -> (Self, cosmic::app::Command) { + fn init(core: Core, _input: Self::Flags) -> (Self, cosmic::app::Task) { let windows = MultiWindow { windows: HashMap::from([( - window::Id::MAIN, + core.main_window_id().unwrap(), Window { input_id: id::Id::new("main"), input_value: String::new(), @@ -54,12 +54,12 @@ impl cosmic::Application for MultiWindow { core, }; - (windows, cosmic::app::Command::none()) + (windows, cosmic::app::Task::none()) } - fn subscription(&self) -> cosmic::iced_futures::Subscription { - event::listen_with(|event, _| { - if let iced::Event::Window(id, window_event) = event { + 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, .. } => { @@ -74,27 +74,24 @@ impl cosmic::Application for MultiWindow { }) } - fn update( - &mut self, - message: Self::Message, - ) -> iced::Command> { + fn update(&mut self, message: Self::Message) -> Task> { match message { Message::CloseWindow(id) => window::close(id), Message::WindowClosed(id) => { self.windows.remove(&id); - Command::none() + Task::none() } Message::WindowOpened(id, ..) => { if let Some(window) = self.windows.get(&id) { - text_input::focus(window.input_id.clone()) + cosmic::widget::text_input::focus(window.input_id.clone()) } else { - Command::none() + Task::none() } } Message::NewWindow => { let count = self.windows.len() + 1; - let (id, spawn_window) = window::spawn(window::Settings { + let (id, spawn_window) = window::open(window::Settings { position: Default::default(), exit_on_close_request: count % 2 == 0, decorations: false, @@ -110,25 +107,23 @@ impl cosmic::Application for MultiWindow { ); _ = self.set_window_title(format!("window_{}", count), id); - spawn_window + spawn_window.map(|id| cosmic::Action::App(Message::WindowOpened(id, None))) } Message::Input(id, value) => { - if let Some(w) = self.windows.get_mut(&window::Id::MAIN) { - if id == w.input_id { - w.input_value = value; - } + if let Some((_, w)) = self.windows.iter_mut().find(|e| e.1.input_id == id) { + w.input_value = value; } - Command::none() + Task::none() } } } - fn view_window(&self, id: window::Id) -> cosmic::prelude::Element { + 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 = text_input("something", &w.input_value) + 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 @@ -136,30 +131,28 @@ impl cosmic::Application for MultiWindow { .focused_window() .map(|i| i == id) .unwrap_or_default(); - let new_window_button = button(text("New Window")).on_press(Message::NewWindow); + 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_items(Alignment::Center), + .align_x(Alignment::Center), ); - let window_content = container(container(content).width(200).center_x()) - .style(cosmic::style::Container::Background) - .width(Length::Fill) - .height(Length::Fill) - .center_x() - .center_y(); + 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 == window::Id::MAIN { + if id == self.core.main_window_id().unwrap() { window_content.into() } else { column![header_bar().focused(focused), window_content].into() } } - fn view(&self) -> cosmic::prelude::Element { - self.view_window(window::Id::MAIN) + 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 index 5ddad7fc..d829df0f 100644 --- a/examples/nav-context/Cargo.toml +++ b/examples/nav-context/Cargo.toml @@ -4,11 +4,10 @@ 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" tracing-log = "0.2.0" [dependencies.libcosmic] path = "../../" -default-features = false -features = ["debug", "winit", "tokio", "xdg-portal"] +features = ["debug", "winit", "tokio", "xdg-portal", "wgpu"] diff --git a/examples/nav-context/src/main.rs b/examples/nav-context/src/main.rs index 9882284a..1992066f 100644 --- a/examples/nav-context/src/main.rs +++ b/examples/nav-context/src/main.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; -use cosmic::app::{Command, Core, Settings}; -use cosmic::iced_core::Size; +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::Size; use cosmic::widget::{menu, nav_bar}; use cosmic::{executor, iced, ApplicationExt, Element}; @@ -70,10 +70,10 @@ pub enum NavMenuAction { } impl menu::Action for NavMenuAction { - type Message = cosmic::app::Message; + type Message = cosmic::Action; fn message(&self) -> Self::Message { - cosmic::app::Message::App(Message::NavMenuAction(*self)) + cosmic::Action::App(Message::NavMenuAction(*self)) } } @@ -105,8 +105,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 nav_model = nav_bar::Model::default(); for (title, content) in input { @@ -131,25 +131,25 @@ impl cosmic::Application for App { fn nav_context_menu( &self, id: nav_bar::Id, - ) -> Option>>> { + ) -> Option>>> { Some(menu::items( &HashMap::new(), vec![ - menu::Item::Button("Move Up", NavMenuAction::MoveUp(id)), - menu::Item::Button("Move Down", NavMenuAction::MoveDown(id)), - menu::Item::Button("Delete", NavMenuAction::Delete(id)), + 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) -> Command { + 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) -> Command { + fn update(&mut self, message: Self::Message) -> Task { match message { Message::NavMenuAction(message) => match message { NavMenuAction::Delete(id) => self.nav_model.remove(id), @@ -168,11 +168,11 @@ impl cosmic::Application for App { }, } - Command::none() + Task::none() } /// Creates a view after each update. - fn view(&self) -> Element { + fn view(&self) -> Element<'_, Self::Message> { let page_content = self .nav_model .active_data::() @@ -183,8 +183,8 @@ impl cosmic::Application for App { 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); + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); Element::from(centered) } @@ -200,10 +200,14 @@ where .unwrap_or("Unknown Page") } - fn update_title(&mut self) -> Command { + 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); - self.set_window_title(window_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 1cd40359..94049270 100644 --- a/examples/open-dialog/Cargo.toml +++ b/examples/open-dialog/Cargo.toml @@ -10,12 +10,11 @@ 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"] diff --git a/examples/open-dialog/src/main.rs b/examples/open-dialog/src/main.rs index 5bd2a8bc..b4b5343f 100644 --- a/examples/open-dialog/src/main.rs +++ b/examples/open-dialog/src/main.rs @@ -4,9 +4,9 @@ //! 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; @@ -34,6 +34,7 @@ pub enum Message { OpenError(Arc), OpenFile, Selected(Url), + Surface(cosmic::surface::Action), } /// The [`App`] stores application-specific state. @@ -65,8 +66,9 @@ 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, file_contents: String::new(), @@ -75,31 +77,26 @@ impl cosmic::Application for App { }; app.set_header_title("Open a file".into()); - let cmd = app.set_window_title( - "COSMIC OpenDialog Demo".into(), - cosmic::iced::window::Id::MAIN, - ); + 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 update(&mut self, message: Self::Message) -> Command { + fn update(&mut self, message: Self::Message) -> Task { match message { Message::Cancelled => { eprintln!("open file dialog cancelled"); } - Message::FileRead(url, contents) => { eprintln!("read file"); self.selected_file = Some(url); self.file_contents = contents; } - Message::Selected(url) => { eprintln!("selected file"); @@ -111,7 +108,7 @@ 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.to_file_path().unwrap(), @@ -144,10 +141,8 @@ impl cosmic::Application for App { Message::FileRead(url, contents) }); } - - // Creates a new open dialog. Message::OpenFile => { - return cosmic::command::future(async move { + return cosmic::task::future(async move { eprintln!("opening new dialog"); #[cfg(feature = "rfd")] @@ -171,13 +166,9 @@ impl cosmic::Application for App { } }); } - - // Displays an error in the application's warning bar. Message::Error(why) => { self.error_status = Some(why); } - - // Displays an error in the application's warning bar. Message::OpenError(why) => { if let Some(why) = Arc::into_inner(why) { let mut source: &dyn std::error::Error = &why; @@ -192,16 +183,20 @@ impl cosmic::Application for App { self.error_status = Some(string); } } - Message::CloseError => { self.error_status = None; } + 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() { @@ -211,7 +206,11 @@ impl cosmic::Application for App { .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() { @@ -231,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 index e84f9eec..fe6105c2 100644 --- a/examples/text-input/Cargo.toml +++ b/examples/text-input/Cargo.toml @@ -4,11 +4,10 @@ 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" tracing-log = "0.2.0" [dependencies.libcosmic] path = "../../" -default-features = false -features = ["debug", "winit", "tokio", "xdg-portal"] +features = ["debug", "winit", "wgpu", "tokio", "xdg-portal"] diff --git a/examples/text-input/src/main.rs b/examples/text-input/src/main.rs index 51404269..c17fcd5c 100644 --- a/examples/text-input/src/main.rs +++ b/examples/text-input/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 @@ -54,8 +54,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, editing: false, @@ -63,7 +63,7 @@ impl cosmic::Application for App { search_id: cosmic::widget::Id::unique(), }; - let commands = Command::batch(vec![ + let commands = Task::batch(vec![ cosmic::widget::text_input::focus(app.search_id.clone()), app.update_title(), ]); @@ -72,7 +72,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::Input(text) => { self.input = text; @@ -83,11 +83,11 @@ impl cosmic::Application for App { } } - Command::none() + Task::none() } /// Creates a view after each update. - fn view(&self) -> Element { + fn view(&self) -> Element<'_, Self::Message> { let editable = cosmic::widget::editable_input( "Input text here", &self.input, @@ -99,13 +99,15 @@ impl cosmic::Application for App { let inline = cosmic::widget::inline_input("", &self.input).on_input(Message::Input); - let column = cosmic::widget::column().push(editable).push(inline); + 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::Horizontal::Center) - .align_y(iced::alignment::Vertical::Center); + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); Element::from(centered) } @@ -115,9 +117,9 @@ impl App where Self: cosmic::Application, { - fn update_title(&mut self) -> Command { + 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.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 06199508..78caabba 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 061995084a5775b4fd7df63dda336be01ddf491c +Subproject commit 78caabba7ef91cd1030da6f70b41d266704ffece diff --git a/justfile b/justfile index 0280da7c..4653434e 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,4 @@ -examples := 'applet application calendar config context-menu cosmic image-button menu multi-window nav-context 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 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/navbar-closed-symbolic.svg b/res/icons/navbar-closed-symbolic.svg deleted file mode 100644 index 46f35e16..00000000 --- a/res/icons/navbar-closed-symbolic.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/res/icons/navbar-open-symbolic.svg b/res/icons/navbar-open-symbolic.svg deleted file mode 100644 index c1f32161..00000000 --- a/res/icons/navbar-open-symbolic.svg +++ /dev/null @@ -1,8 +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/icons/window-close-symbolic.svg b/res/icons/window-close-symbolic.svg deleted file mode 100644 index 25336395..00000000 --- a/res/icons/window-close-symbolic.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/icons/window-maximize-symbolic.svg b/res/icons/window-maximize-symbolic.svg deleted file mode 100644 index ef66334e..00000000 --- a/res/icons/window-maximize-symbolic.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/icons/window-minimize-symbolic.svg b/res/icons/window-minimize-symbolic.svg deleted file mode 100644 index fdcf99b4..00000000 --- a/res/icons/window-minimize-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 80682d11..00000000 --- a/src/app/command.rs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2023 System76 -// SPDX-License-Identifier: MPL-2.0 - -use iced::window; - -/// 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(id: Option) -> iced::Command> { - crate::command::drag(id).map(Message::Cosmic) -} - -pub fn maximize( - id: Option, - maximized: bool, -) -> iced::Command> { - crate::command::maximize(id, maximized).map(Message::Cosmic) -} - -pub fn minimize(id: Option) -> iced::Command> { - crate::command::minimize(id).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( - id: Option, - title: String, -) -> iced::Command> { - crate::command::set_title(id, title).map(Message::Cosmic) -} - -pub fn set_windowed(id: Option) -> iced::Command> { - crate::command::set_windowed(id).map(Message::Cosmic) -} - -pub fn toggle_maximize(id: Option) -> iced::Command> { - crate::command::toggle_maximize(id).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/cosmic.rs b/src/app/cosmic.rs index 8b437699..030ed041 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -1,202 +1,468 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 +use std::borrow::Borrow; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use super::{command, Application, ApplicationExt, Core, Subscription}; -use crate::config::CosmicTk; -use crate::theme::{self, Theme, ThemeType, THEME}; -use crate::widget::nav_bar; -use crate::{keyboard_nav, Element}; -#[cfg(feature = "wayland")] +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; -#[cfg(all(feature = "winit", feature = "multi-window"))] -use iced::multi_window::Application as IcedApplication; -#[cfg(feature = "wayland")] -use iced::wayland::Application as IcedApplication; -#[cfg(not(any(feature = "multi-window", feature = "wayland")))] +#[cfg(not(any(feature = "multi-window", feature = "wayland", target_os = "linux")))] use iced::Application as IcedApplication; -use iced::{window, Command}; +#[cfg(all(feature = "wayland", target_os = "linux"))] +use iced::event::wayland; +use iced::{Task, theme, window}; use iced_futures::event::listen_with; -#[cfg(not(feature = "wayland"))] -use iced_runtime::command::Action; -#[cfg(not(feature = "wayland"))] -use iced_runtime::window::Action as WindowAction; +#[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), - /// Activates a context menu for an item from the nav bar. - NavBarContext(nav_bar::Id), - /// Set scaling factor - ScaleFactor(f32), - /// 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), - /// Updates the window maximized state - WindowMaximized(window::Id, bool), - /// 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), - /// Notifies that a surface was closed. - /// Any data relating to the surface should be cleaned up. - SurfaceClosed(window::Id), - /// Activate the application - Activate(String), - ShowWindowMenu, - #[cfg(feature = "xdg-portal")] - DesktopSettings(crate::theme::portal::Desktop), - /// Window focus changed - Focus(window::Id), - /// Window focus lost - Unfocus(window::Id), - /// Tracks updates to window suggested size. - #[cfg(feature = "applet")] - Configure(cctk::sctk::shell::xdg::window::WindowConfigure), +#[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 struct Cosmic { +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 IcedApplication 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; - - fn new((mut core, flags): Self::Flags) -> (Self, iced::Command) { - #[cfg(feature = "dbus-config")] + 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); 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(not(any(feature = "multi-window", feature = "wayland")))] - fn title(&self) -> String { + #[cfg(not(feature = "multi-window"))] + pub fn title(&self) -> String { self.app.title().to_string() } - #[cfg(any(feature = "multi-window", feature = "wayland"))] - fn title(&self, id: window::Id) -> String { + #[cfg(feature = "multi-window")] + pub fn title(&self, id: window::Id) -> String { self.app.title(id).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), + #[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() } - #[cfg(not(any(feature = "multi-window", feature = "wayland")))] - fn scale_factor(&self) -> f64 { + 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(any(feature = "multi-window", feature = "wayland"))] - fn scale_factor(&self, _id: window::Id) -> f64 { + #[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(), - })) + } } } #[allow(clippy::too_many_lines)] - fn subscription(&self) -> Subscription { - let window_events = listen_with(|event, _| { + #[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)); } - iced::Event::Window(id, window::Event::Closed) => { - return Some(Message::SurfaceClosed(id)) + iced::Event::Window(window::Event::Opened { .. }) => { + return Some(Action::Opened(id)); } - iced::Event::Window(id, window::Event::Focused) => return Some(Message::Focus(id)), - iced::Event::Window(id, window::Event::Unfocused) => { - return Some(Message::Unfocus(id)) + iced::Event::Window(window::Event::Closed) => { + return Some(Action::SurfaceClosed(id)); } - #[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::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)); - } - wayland::Event::Popup(wayland::PopupEvent::Done, _, id) - | wayland::Event::Layer(wayland::LayerEvent::Done, _, id) => { - return Some(Message::SurfaceClosed(id)); - } - #[cfg(feature = "applet")] - wayland::Event::Window(WindowEvent::Configure(conf), _surface, id) - if id == window::Id::MAIN => - { - return Some(Message::Configure(conf)); - } - _ => (), - }, + } _ => (), } @@ -204,16 +470,26 @@ where }); let mut subscriptions = vec![ - self.app.subscription().map(super::Message::App), + self.app.subscription().map(crate::Action::App), self.app .core() .watch_config::(crate::config::ID) .map(|update| { - for why in update.errors { + 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"); } - super::Message::Cosmic(Message::ToolkitConfig(update.config)) + crate::Action::Cosmic(Action::ToolkitConfig(update.config)) }), self.app .core() @@ -233,102 +509,137 @@ where }, ) .map(|update| { - for why in update.errors { + for why in update + .errors + .into_iter() + .filter(cosmic_config::Error::is_err) + { tracing::error!(?why, "cosmic theme config update error"); } - Message::SystemThemeChange( + Action::SystemThemeChange( update.keys, crate::theme::Theme::system(Arc::new(update.config)), ) }) - .map(super::Message::Cosmic), + .map(crate::Action::Cosmic), self.app .core() .watch_config::(cosmic_theme::THEME_MODE_ID) .map(|update| { - for e in update.errors { - tracing::error!("{e}"); + for error in update + .errors + .into_iter() + .filter(cosmic_config::Error::is_err) + { + tracing::error!(?error, "error reading system theme mode update"); } - Message::SystemThemeModeChange(update.keys, update.config) + Action::SystemThemeModeChange(update.keys, update.config) }) - .map(super::Message::Cosmic), - window_events.map(super::Message::Cosmic), + .map(crate::Action::Cosmic), + window_events.map(crate::Action::Cosmic), #[cfg(feature = "xdg-portal")] crate::theme::portal::desktop_settings() - .map(Message::DesktopSettings) - .map(super::Message::Cosmic), + .map(Action::DesktopSettings) + .map(crate::Action::Cosmic), ]; if self.app.core().keyboard_nav { subscriptions.push( keyboard_nav::subscription() - .map(Message::KeyboardNav) - .map(super::Message::Cosmic), + .map(Action::KeyboardNav) + .map(crate::Action::Cosmic), ); } #[cfg(feature = "single-instance")] if self.app.core().single_instance { - subscriptions.push(super::single_instance_subscription::()); + subscriptions.push(crate::dbus_activation::subscription::()); } Subscription::batch(subscriptions) } - #[cfg(not(any(feature = "multi-window", feature = "wayland")))] - fn theme(&self) -> Self::Theme { + #[cfg(not(feature = "multi-window"))] + pub fn theme(&self) -> Theme { crate::theme::active() } - #[cfg(any(feature = "multi-window", feature = "wayland"))] - fn theme(&self, _id: window::Id) -> Self::Theme { + #[cfg(feature = "multi-window")] + pub fn theme(&self, _id: window::Id) -> Theme { crate::theme::active() } - #[cfg(any(feature = "multi-window", feature = "wayland"))] - fn view(&self, id: window::Id) -> Element { - if id != self.app.main_window_id() { - return self.app.view_window(id).map(super::Message::App); + #[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); } - if self.app.core().window.use_template { + let view = if self.app.core().window.use_template { self.app.view_main() } else { - self.app.view().map(super::Message::App) - } + self.app.view().map(crate::Action::App) + }; + + #[cfg(all(target_env = "gnu", not(target_os = "windows")))] + crate::malloc::trim(0); + + view } - #[cfg(not(any(feature = "multi-window", feature = "wayland")))] - fn view(&self) -> Element { - self.app.view_main() + #[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> { - iced_sctk::commands::window::close_window(self.app.main_window_id()) - } - - #[cfg(not(feature = "wayland"))] #[allow(clippy::unused_self)] - pub fn close(&mut self) -> iced::Command> { - iced::Command::single(Action::Window(WindowAction::Close( - self.app.main_window_id(), - ))) + #[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::WindowMaximized(id, maximized) => { - if self.app.main_window_id() == 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; } } - Message::WindowResize(id, width, height) => { - if self.app.main_window_id() == id { + 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); } @@ -336,15 +647,19 @@ impl Cosmic { 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) - #[cfg(not(feature = "wayland"))] - return iced::window::fetch_maximized(id, move |maximized| { - super::Message::Cosmic(Message::WindowMaximized(id, maximized)) + return iced::window::is_maximized(id).map(move |maximized| { + crate::Action::Cosmic(Action::WindowMaximized(id, maximized)) }); } - #[cfg(feature = "wayland")] - Message::WindowState(id, state) => { - if self.app.main_window_id() == 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 @@ -354,12 +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 self.app.main_window_id() == id { + #[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 = @@ -369,51 +721,49 @@ impl Cosmic { } } - Message::KeyboardNav(message) => match message { - keyboard_nav::Message::FocusNext => { - return iced::widget::focus_next().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::FocusPrevious => { - return iced::widget::focus_previous().map(super::Message::Cosmic) + keyboard_nav::Action::FocusPrevious => { + return iced::widget::operation::focus_previous().map(crate::Action::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_maximize(Some(self.app.main_window_id())) - } + keyboard_nav::Action::Fullscreen => return self.app.core().toggle_maximize(None), }, - Message::ContextDrawer(show) => { + Action::ContextDrawer(show) => { self.app.core_mut().set_show_context(show); return self.app.on_context_drawer(); } - Message::Drag => return command::drag(Some(self.app.main_window_id())), + Action::Drag => return self.app.core().drag(None), - Message::Minimize => return command::minimize(Some(self.app.main_window_id())), + Action::Minimize => return self.app.core().minimize(None), - Message::Maximize => return command::toggle_maximize(Some(self.app.main_window_id())), + Action::Maximize => return self.app.core().toggle_maximize(None), - Message::NavBar(key) => { + Action::NavBar(key) => { self.app.core_mut().nav_bar_set_toggled_condensed(false); return self.app.on_nav_select(key); } - Message::NavBarContext(key) => { + Action::NavBarContext(key) => { self.app.core_mut().nav_bar_set_context(key); return self.app.on_nav_context(key); } - Message::ToggleNavBar => { + 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) => { + Action::AppThemeChange(mut theme) => { if let ThemeType::System { theme: _, .. } = theme.theme_type { self.app.core_mut().theme_sub_counter += 1; @@ -429,11 +779,11 @@ impl Cosmic { THEME.lock().unwrap().set_theme(theme.theme_type); } - Message::SystemThemeChange(keys, 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::Command::none(); + 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. @@ -461,26 +811,107 @@ impl Cosmic { 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 => { + Action::Close => { return match self.app.on_app_exit() { Some(message) => self.app.update(message), None => self.close(), }; } - Message::SystemThemeModeChange(keys, mode) => { - if !keys.contains(&"is_dark") { - return iced::Command::none(); - } + Action::SystemThemeModeChange(keys, mode) => { if match THEME.lock().unwrap().theme_type { ThemeType::System { theme: _, @@ -488,15 +919,16 @@ impl Cosmic { } => prefer_dark.is_some(), _ => false, } { - return iced::Command::none(); + return iced::Task::none(); } let mut cmds = vec![self.app.system_theme_mode_update(&keys, &mode)]; let core = self.app.core_mut(); - let prev_is_dark = core.system_is_dark(); core.system_theme_mode = mode; let is_dark = core.system_is_dark(); - let changed = prev_is_dark != 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 mut new_theme = if is_dark { @@ -521,32 +953,164 @@ impl Cosmic { 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 { + 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 Command::batch(cmds); + return Task::batch(cmds); } - Message::Activate(_token) => { - #[cfg(feature = "wayland")] - return iced_sctk::commands::activation::activate( - self.app.main_window_id(), - #[allow(clippy::used_underscore_binding)] - _token, - ); - } - Message::SurfaceClosed(id) => { - if let Some(msg) = self.app.on_close_requested(id) { - return self.app.update(msg); + 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::ShowWindowMenu => { - return window::show_window_menu(window::Id::MAIN); + + 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")] - Message::DesktopSettings(crate::theme::portal::Desktop::ColorScheme(s)) => { + Action::DesktopSettings(crate::theme::portal::Desktop::ColorScheme(s)) => { use ashpd::desktop::settings::ColorScheme; if match THEME.lock().unwrap().theme_type { ThemeType::System { @@ -555,7 +1119,7 @@ impl Cosmic { } => prefer_dark.is_some(), _ => false, } { - return iced::Command::none(); + return iced::Task::none(); } let is_dark = match s { ColorScheme::NoPreference => None, @@ -563,10 +1127,13 @@ impl Cosmic { ColorScheme::PreferLight => Some(false), }; let core = self.app.core_mut(); - let prev_is_dark = core.system_is_dark(); + core.portal_is_dark = is_dark; let is_dark = core.system_is_dark(); - let changed = prev_is_dark != 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 { @@ -586,7 +1153,7 @@ impl Cosmic { } } #[cfg(feature = "xdg-portal")] - Message::DesktopSettings(crate::theme::portal::Desktop::Accent(c)) => { + 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(); @@ -595,7 +1162,7 @@ impl Cosmic { if cur_accent.distance_squared(*c) < 0.00001 { // skip calculations if we already have the same color - return iced::Command::none(); + return iced::Task::none(); } { @@ -615,11 +1182,11 @@ impl Cosmic { } } #[cfg(feature = "xdg-portal")] - Message::DesktopSettings(crate::theme::portal::Desktop::Contrast(_)) => { + Action::DesktopSettings(crate::theme::portal::Desktop::Contrast(_)) => { // TODO when high contrast is integrated in settings and all custom themes } - Message::ToolkitConfig(config) => { + 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 @@ -630,34 +1197,180 @@ impl Cosmic { *crate::config::COSMIC_TK.write().unwrap() = config; } - Message::Focus(f) => { - self.app.core_mut().focused_window = Some(f); + 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]; } - Message::Unfocus(id) => { + Action::Unfocus(id) => { let core = self.app.core_mut(); - if core.focused_window.as_ref().is_some_and(|cur| *cur == id) { - core.focused_window = None; + if core.focused_window().as_ref().is_some_and(|cur| *cur == id) { + core.focused_window.pop(); } } #[cfg(feature = "applet")] - Message::Configure(configure) => { - if let Some(w) = configure.new_size.0 { - self.app.core_mut().set_window_width(w.get()); - } - if let Some(h) = configure.new_size.1 { - self.app.core_mut().set_window_height(h.get()); - } - self.app.core_mut().applet.configure = Some(configure); + 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() } } impl Cosmic { pub fn new(app: App) -> Self { - Self { app } + Self { + app, + #[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 23f9ff2c..f78beac7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,82 +6,41 @@ //! 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 - } - - impl From for Message { - fn from(value: M) -> Self { - Self::App(value) - } - } -} - -use std::borrow::Cow; - -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, horizontal_space, id_container, menu, nav_bar, popover}; +use crate::widget::{container, id_container, menu, nav_bar, popover, space}; use apply::Apply; -#[cfg(all(feature = "winit", feature = "multi-window"))] -use iced::{multi_window::Application as IcedApplication, window}; -#[cfg(any(not(feature = "winit"), not(feature = "multi-window")))] -use iced::{window, Application as IcedApplication}; use iced::{Length, Subscription}; -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::{interface, proxy, zvariant::Value}, -}; +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)> { +) -> (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.width as u32); - core.set_window_height(settings.size.height as u32); + core.set_window_width(settings.size.width); + core.set_window_height(settings.size.height); if let Some(icon_theme) = settings.default_icon_theme { crate::icon_theme::set_default(icon_theme); @@ -91,211 +50,139 @@ pub(crate) fn iced_settings( THEME.lock().unwrap().set_theme(settings.theme.theme_type); - let mut iced = iced::Settings::with_flags((core, flags)); + 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 = iced::Pixels(settings.default_text_size); - iced.exit_on_close_request = settings.exit_on_close; - #[cfg(not(feature = "wayland"))] - { - let exit_on_close = settings.exit_on_close; - iced.window.exit_on_close_request = exit_on_close; - } + 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(all(not(feature = "wayland"), target_os = "linux"))] + #[cfg(target_os = "linux")] { - iced.window.platform_specific.application_id = App::APP_ID.to_string(); + 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(feature = "wayland")] - { - 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.width as u32, settings.size.height as u32).into(), - size_limits: settings.size_limits, - title: None, - transparent: settings.transparent, - xdg_activation_token: std::env::var("XDG_ACTIVATION_TOKEN").ok(), - ..SctkWindowSettings::default() - }) - }; - } - - #[cfg(not(feature = "wayland"))] - { - if let Some(border_size) = settings.resizable { - iced.window.resize_border = border_size as u32; - iced.window.resizable = true; - } - iced.window.decorations = !settings.client_decorations; - iced.window.size = settings.size; - let min_size = settings.size_limits.min(); - if min_size != iced::Size::ZERO { - iced.window.min_size = Some(min_size); - } - let max_size = settings.size_limits.max(); - if max_size != iced::Size::INFINITY { - iced.window.max_size = Some(max_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")] -#[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")] -#[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() } } @@ -310,6 +197,11 @@ where 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") @@ -326,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()); @@ -347,7 +239,8 @@ where } else { p.activate(platform_data) } - } { + }; + match res { Ok(()) => { tracing::info!("Successfully activated another instance"); true @@ -362,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() + } } } @@ -408,46 +344,42 @@ 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, 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> { + fn dialog(&self) -> Option> { None } /// Displays a footer at the bottom of the application window when `Some`. - fn footer(&self) -> Option> { + 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() } - /// Get the main [`window::Id`], which is [`window::Id::MAIN`] by default - fn main_window_id(&self) -> window::Id { - window::Id::MAIN - } - /// Allows overriding the default nav bar widget. - fn nav_bar(&self) -> Option>> { + fn nav_bar(&self) -> Option>> { if !self.core().nav_bar_active() { return None; } @@ -455,13 +387,12 @@ where let nav_model = self.nav_model()?; let mut nav = - crate::widget::nav_bar(nav_model, |id| Message::Cosmic(cosmic::Message::NavBar(id))) - .on_context(|id| Message::Cosmic(cosmic::Message::NavBarContext(id))) + 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() - // XXX both must be shrink to avoid flex layout from ignoring it .width(iced::Length::Shrink) - .height(iced::Length::Shrink); + .height(iced::Length::Fill); if !self.core().is_condensed() { nav = nav.max_width(280); @@ -471,7 +402,10 @@ where } /// Shows a context menu for the active nav bar item. - fn nav_context_menu(&self, id: nav_bar::Id) -> Option>>> { + fn nav_context_menu( + &self, + id: nav_bar::Id, + ) -> Option>>> { None } @@ -491,32 +425,32 @@ where } // Called when context drawer is toggled - fn on_context_drawer(&mut self) -> Command { - Command::none() + fn on_context_drawer(&mut self) -> Task { + Task::none() } /// Called when the escape key is pressed. - fn on_escape(&mut self) -> Command { - 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) -> Command { - 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) -> Command { - Command::none() + 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) -> Command { - 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 { @@ -524,8 +458,8 @@ where } /// Respond to an application-specific message. - fn update(&mut self, message: Self::Message) -> Command { - Command::none() + fn update(&mut self, message: Self::Message) -> Task { + Task::none() } /// Respond to a system theme change @@ -533,8 +467,8 @@ where &mut self, keys: &[&'static str], new_theme: &cosmic_theme::Theme, - ) -> Command { - Command::none() + ) -> Task { + Task::none() } /// Respond to a system theme mode change @@ -542,54 +476,57 @@ where &mut self, keys: &[&'static str], new_theme: &cosmic_theme::ThemeMode, - ) -> Command { - Command::none() + ) -> 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 { + 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) -> Command { - 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) -> Command; + fn drag(&mut self) -> Task; /// Maximizes the window. - fn maximize(&mut self) -> Command; + fn maximize(&mut self) -> Task; /// Minimizes the window. - fn minimize(&mut self) -> Command; + fn minimize(&mut self) -> Task; /// Get the title of the main window. - #[cfg(not(any(feature = "multi-window", feature = "wayland")))] + #[cfg(not(feature = "multi-window"))] fn title(&self) -> &str; - #[cfg(any(feature = "multi-window", feature = "wayland"))] + #[cfg(feature = "multi-window")] /// Get the title of a window. fn title(&self, id: window::Id) -> &str; - /// Set the context drawer title. - fn set_context_title(&mut self, title: String) { - self.core_mut().set_context_title(title); - } - /// Set the context drawer visibility. fn set_show_context(&mut self, show: bool) { self.core_mut().set_show_context(show); @@ -600,99 +537,139 @@ pub trait ApplicationExt: Application { self.core_mut().set_header_title(title); } - #[cfg(not(any(feature = "multi-window", feature = "wayland")))] + #[cfg(not(feature = "multi-window"))] /// Set the title of the main window. - fn set_window_title(&mut self, title: String) -> Command; + fn set_window_title(&mut self, title: String) -> Task; - #[cfg(any(feature = "multi-window", feature = "wayland"))] + #[cfg(feature = "multi-window")] /// Set the title of a window. - fn set_window_title(&mut self, title: String, id: window::Id) -> Command; + 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) -> Command { - command::drag(Some(self.main_window_id())) + fn drag(&mut self) -> Task { + self.core().drag(None) } - fn maximize(&mut self) -> Command { - command::maximize(Some(self.main_window_id()), true) + fn maximize(&mut self) -> Task { + self.core().maximize(None, true) } - fn minimize(&mut self) -> Command { - command::minimize(Some(self.main_window_id())) + fn minimize(&mut self) -> Task { + self.core().minimize(None) } - #[cfg(any(feature = "multi-window", feature = "wayland"))] + #[cfg(feature = "multi-window")] fn title(&self, id: window::Id) -> &str { self.core().title.get(&id).map_or("", |s| s.as_str()) } - #[cfg(not(any(feature = "multi-window", feature = "wayland")))] + #[cfg(not(feature = "multi-window"))] fn title(&self) -> &str { self.core() - .title - .get(&self.main_window_id()) - .map_or("", |s| s.as_str()) + .main_window_id() + .and_then(|id| self.core().title.get(&id).map(std::string::String::as_str)) + .unwrap_or("") } - #[cfg(any(feature = "multi-window", feature = "wayland"))] - fn set_window_title(&mut self, title: String, id: window::Id) -> Command { + #[cfg(feature = "multi-window")] + fn set_window_title(&mut self, title: String, id: window::Id) -> Task { self.core_mut().title.insert(id, title.clone()); - command::set_title(Some(id), title) + self.core().set_title(Some(id), title) } - #[cfg(not(any(feature = "multi-window", feature = "wayland")))] - fn set_window_title(&mut self, title: String) -> Command { - let id = self.main_window_id(); + #[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()); - Command::none() + 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 - .focused_window() - .is_some_and(|i| i == self.main_window_id()); + .focus_chain() + .iter() + .any(|i| Some(*i) == self.core().main_window_id()); + + 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(4); + 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(nav.into()); + 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() { - // Manual spacing must be used due to state workarounds below - if has_nav { - widgets.push(horizontal_space(Length::Fixed(8.0)).into()); - } - - let main_content = self.view().map(Message::App); + let main_content = self.view(); //TODO: reduce duplication let context_width = core.context_width(has_nav); - if core.window.context_is_overlay { + if core.window.context_is_overlay && show_context { if let Some(context) = self.context_drawer() { widgets.push( - context_drawer( - &core.window.context_title, - Message::Cosmic(cosmic::Message::ContextDrawer(false)), + crate::widget::context_drawer( + context.title, + context.actions, + context.header, + context.footer, + context.on_close, main_content, - context.map(Message::App), + context.content, context_width, ) .apply(|drawer| { @@ -700,112 +677,197 @@ impl ApplicationExt for App { 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(main_content); + widgets.push( + container(main_content.map(crate::Action::App)) + .padding(main_content_padding) + .into(), + ); } } else { //TODO: hide content when out of space - widgets.push(main_content); + 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( - &core.window.context_title, - context.map(Message::App), - Message::Cosmic(cosmic::Message::ContextDrawer(false)), + context.title, + context.actions, + context.header, + context.footer, + context.content, + context.on_close, context_width, ) - .apply(crate::widget::container) + .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(horizontal_space(Length::Shrink).into()); + widgets.push(space::horizontal().width(Length::Shrink).into()); } } } widgets }); + let content_col = crate::widget::column::with_capacity(2) - .spacing(8) .push(content_row) - .push_maybe(self.footer().map(|footer| footer.map(Message::App))); - let content: Element<_> = if core.window.content_container { + .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 - .apply(crate::widget::container) - .padding([0, 8, 8, 8]) .width(iced::Length::Fill) .height(iced::Length::Fill) - .style(crate::theme::Container::WindowBackground) .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_right_click(Message::Cosmic(cosmic::Message::ShowWindowMenu)) - .on_double_click(Message::Cosmic(cosmic::Message::Maximize)); + .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) - }) - .style(crate::theme::Button::HeaderBar); + crate::Action::Cosmic(Action::ToggleNavBar) + }); header = header.start(toggle); } if core.window.show_close { - header = header.on_close(Message::Cosmic(cosmic::Message::Close)); + header = header.on_close(crate::Action::Cosmic(Action::Close)); } if core.window.show_maximize && crate::config::show_maximize() { - header = header.on_maximize(Message::Cosmic(cosmic::Message::Maximize)); + header = header.on_maximize(crate::Action::Cosmic(Action::Maximize)); } if core.window.show_minimize && crate::config::show_minimize() { - header = header.on_minimize(Message::Cosmic(cosmic::Message::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.apply(|w| id_container(w, iced_core::id::Id::new("COSMIC_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(content); + .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() + } + })); // 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 @@ -814,7 +876,7 @@ impl ApplicationExt for App { .dialog() .map(|w| Element::from(id_container(w, iced_core::id::Id::new("COSMIC_dialog")))) { - popover = popover.popup(dialog.map(Message::App)); + popover = popover.popup(dialog.map(crate::Action::App)); } let view_element: Element<_> = popover.into(); @@ -822,90 +884,23 @@ impl ApplicationExt for App { } } -#[cfg(feature = "single-instance")] -fn single_instance_subscription() -> Subscription> { - use iced_futures::futures::StreamExt; - - 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); - } - - #[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!(); - } - }, - ) -} - const EMBEDDED_FONTS: &[&[u8]] = &[ - include_bytes!("../../res/Fira/FiraSans-Light.otf"), - include_bytes!("../../res/Fira/FiraSans-Regular.otf"), - include_bytes!("../../res/Fira/FiraSans-SemiBold.otf"), - include_bytes!("../../res/Fira/FiraSans-Bold.otf"), - include_bytes!("../../res/Fira/FiraMono-Regular.otf"), + 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"), ]; +#[cold] fn preload_fonts() { let mut font_system = iced::advanced::graphics::text::font_system() .write() .unwrap(); EMBEDDED_FONTS - .into_iter() + .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 af976d96..5c903f09 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -3,9 +3,9 @@ //! Configure a new COSMIC application. -use crate::{font, Theme}; -use iced_core::layout::Limits; +use crate::{Theme, font}; use iced_core::Font; +use iced_core::layout::Limits; /// Configure a new COSMIC application. #[allow(clippy::struct_excessive_bools)] @@ -16,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. @@ -39,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, @@ -58,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 { @@ -75,15 +80,15 @@ 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::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() @@ -94,6 +99,7 @@ impl Default for Settings { theme: crate::theme::system_preference(), 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 2ee82300..48721e1c 100644 --- a/src/applet/mod.rs +++ b/src/applet/mod.rs @@ -1,45 +1,62 @@ #[cfg(feature = "applet-token")] pub mod token; +use crate::app::{BootData, BootDataInner, cosmic}; use crate::{ - app::Core, - cctk::sctk, - iced::{ - self, - alignment::{Horizontal, Vertical}, - widget::Container, - window, Color, Length, Limits, Rectangle, - }, - iced_style, iced_widget, - theme::{self, system_dark, system_light, Button, THEME}, - widget::{self, layer_container}, Application, Element, Renderer, + app::iced_settings, + cctk::sctk, + theme::{self, Button, THEME, system_dark, system_light}, + widget::{ + self, + autosize::{self, Autosize, autosize}, + column::Column, + layer_container, + row::Row, + space::horizontal, + space::vertical, + }, }; -use cctk::sctk::shell::xdg::window::WindowConfigure; + pub use cosmic_panel_config; use cosmic_panel_config::{CosmicPanelBackground, PanelAnchor, PanelSize}; -use cosmic_theme::Theme; -use iced::Pixels; -use iced_core::{Padding, Shadow}; -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::{borrow::Cow, num::NonZeroU32, 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; + +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 suggested size of the window. + /// Includes the configured size of the window. /// This can be used by apples to handle overflow themselves. - pub configure: Option, + pub suggested_bounds: Option, + /// Ratio of overlap for applet padding. + pub padding_overlap: f32, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -70,7 +87,7 @@ impl From for PanelType { match value.as_str() { "Panel" => PanelType::Panel, "Dock" => PanelType::Dock, - other => PanelType::Other(other.to_string()), + _ => PanelType::Other(value), } } } @@ -88,13 +105,21 @@ 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()), - configure: None, + padding_overlap: str::parse( + &std::env::var("COSMIC_PANEL_PADDING_OVERLAP").unwrap_or_default(), + ) + .unwrap_or(0.0), + suggested_bounds: None, } } } @@ -103,7 +128,7 @@ impl Context { #[must_use] pub fn suggested_size(&self, is_symbolic: bool) -> (u16, u16) { match &self.size { - Size::PanelSize(ref size) => { + Size::PanelSize(size) => { let s = size.get_applet_icon_size(is_symbolic) as u16; (s, s) } @@ -114,29 +139,40 @@ impl Context { #[must_use] pub fn suggested_window_size(&self) -> (NonZeroU32, NonZeroU32) { let suggested = self.suggested_size(true); - let applet_padding = self.suggested_padding(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 - .configure + .suggested_bounds .as_ref() - .and_then(|c| c.new_size.0.map(|w| w)) + .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 + applet_padding as u32 * 2).unwrap() + NonZeroU32::new(suggested.0 as u32 + horizontal_padding as u32 * 2).unwrap() }); + let configured_height = self - .configure + .suggested_bounds .as_ref() - .and_then(|c| c.new_size.1.map(|h| h)) + .and_then(|c| NonZeroU32::new(c.height as u32)) .unwrap_or_else(|| { - NonZeroU32::new(suggested.1 as u32 + applet_padding as u32 * 2).unwrap() + 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 { + pub fn suggested_padding(&self, is_symbolic: bool) -> (u16, u16) { match &self.size { - Size::PanelSize(ref size) => size.get_applet_padding(is_symbolic), - Size::Hardcoded(_) => 8, + Size::PanelSize(size) => ( + size.get_applet_shrinkable_padding(is_symbolic), + size.get_applet_padding(is_symbolic), + ), + Size::Hardcoded(_) => (12, 8), } } @@ -145,23 +181,21 @@ impl Context { 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(true); - let width = f32::from(width); - let height = f32::from(height); - let applet_padding = self.suggested_padding(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(iced_core::Size::new( - width + applet_padding as f32 * 2., - height + applet_padding as f32 * 2., - )) - .size_limits( - Limits::NONE - .min_height(height as f32 + applet_padding as f32 * 2.0) - .min_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(14.0) .default_font(crate::font::default()) @@ -169,6 +203,7 @@ impl Context { if let Some(theme) = self.theme() { settings = settings.theme(theme); } + settings.exit_on_close = true; settings } @@ -177,67 +212,160 @@ impl Context { matches!(self.anchor, PanelAnchor::Top | PanelAnchor::Bottom) } - #[must_use] - pub fn icon_button_from_handle<'a, Message: 'static>( + 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 = self.suggested_padding(icon.symbolic); - let (mut configured_width, mut configured_height) = self.suggested_window_size(); - - // Adjust the width to include padding and force the crosswise dim to match the window size - let is_horizontal = self.is_horizontal(); - if is_horizontal { - configured_width = - NonZeroU32::new(suggested.0 as u32 + applet_padding as u32 * 2).unwrap(); + 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 { - configured_height = - NonZeroU32::new(suggested.1 as u32 + applet_padding as u32 * 2).unwrap(); - } + (applet_padding_minor_axis, applet_padding_major_axis) + }; let symbolic = icon.symbolic; - - crate::widget::button::custom( - layer_container( - widget::icon(icon) - .style(if symbolic { - theme::Svg::Custom(Rc::new(|theme| crate::iced_style::svg::Appearance { - 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)), - ) - .align_x(Horizontal::Center) - .align_y(Vertical::Center) - .width(Length::Fill) - .height(Length::Fill), - ) - .width(Length::Fixed(configured_width.get() as f32)) - .height(Length::Fixed(configured_height.get() as f32)) - .style(Button::AppletIcon) + 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) } - #[must_use] - pub fn icon_button<'a, Message: 'static>( + 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> { + let suggested_size = self.suggested_size(true); self.icon_button_from_handle( widget::icon::from_name(icon_name) .symbolic(true) - .size(self.suggested_size(true).0) + .size(suggested_size.0) .into(), ) } + 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, crate::Theme, 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), @@ -245,12 +373,12 @@ impl Context { PanelAnchor::Bottom => (Vertical::Bottom, Horizontal::Center), }; - Container::::new( - Container::::new(content).style(theme::Container::custom( - |theme| { + autosize( + Container::::new( + Container::::new(content).style(|theme| { let cosmic = theme.cosmic(); - let corners = cosmic.corner_radii.clone(); - Appearance { + 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 { @@ -260,14 +388,22 @@ impl Context { }, 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), ) - .width(Length::Shrink) - .height(Length::Shrink) - .align_x(horizontal_align) - .align_y(vertical_align) } #[must_use] @@ -281,8 +417,13 @@ impl Context { height_padding: Option, ) -> SctkPopupSettings { let (width, height) = self.suggested_size(true); - let applet_padding = self.suggested_padding(true); - let pixel_offset = 8; + 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), @@ -300,18 +441,55 @@ 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 { @@ -332,12 +510,24 @@ impl Context { pub fn text<'a>(&self, msg: impl Into>) -> crate::widget::Text<'a, crate::Theme> { let msg = msg.into(); let t = match self.size { - Size::PanelSize(PanelSize::XL) => crate::widget::text::title2, - Size::PanelSize(PanelSize::L) => crate::widget::text::title3, - Size::PanelSize(PanelSize::M) => crate::widget::text::title4, - Size::PanelSize(PanelSize::S) => crate::widget::text::body, - Size::PanelSize(PanelSize::XS) => crate::widget::text::body, 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()) } @@ -348,77 +538,79 @@ 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 (width, height) = (settings.size.width as u32, settings.size.height as u32); + THEME + .lock() + .unwrap() + .set_theme(settings.theme.theme_type.clone()); - let mut core = Core::default(); - core.window.show_window_menu = false; + 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(width); - core.set_window_height(height); + window_settings.decorations = false; + window_settings.exit_on_close_request = true; + window_settings.resizable = false; + window_settings.resize_border = 0; - THEME.lock().unwrap().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.into(); - 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: (width, height), - 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::widget::button::custom(content) - .style(Button::AppletMenu) + .class(Button::AppletMenu) .padding(menu_control_padding()) .width(Length::Fill) } 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 10e32a28..00000000 --- a/src/command/mod.rs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright 2023 System76 -// SPDX-License-Identifier: MPL-2.0 - -//! Create asynchronous actions to be performed in the background. - -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, Y: 'static>( - commands: impl IntoIterator>, -) -> Command { - Command::batch(commands).map(Into::into) -} - -/// Yields a command which will run the future on the runtime executor. -pub fn future, Y>(future: impl Future + Send + 'static) -> Command { - Command::single(Action::Future(Box::pin(async move { future.await.into() }))) -} - -/// Yields a command which will return a message. -pub fn message, Y>(message: X) -> Command { - future(async move { message.into() }) -} - -/// Initiates a window drag. -#[cfg(feature = "wayland")] -pub fn drag(id: Option) -> Command { - iced_sctk::commands::window::start_drag_window(id.unwrap_or(window::Id::MAIN)) -} - -/// Initiates a window drag. -#[cfg(not(feature = "wayland"))] -pub fn drag(id: Option) -> Command { - iced_runtime::window::drag(id.unwrap_or(window::Id::MAIN)) -} - -/// Maximizes the window. -#[cfg(feature = "wayland")] -pub fn maximize(id: Option, maximized: bool) -> Command { - iced_sctk::commands::window::maximize(id.unwrap_or(window::Id::MAIN), maximized) -} - -/// Maximizes the window. -#[cfg(not(feature = "wayland"))] -pub fn maximize(id: Option, maximized: bool) -> Command { - iced_runtime::window::maximize(id.unwrap_or(window::Id::MAIN), maximized) -} - -/// Minimizes the window. -#[cfg(feature = "wayland")] -pub fn minimize(id: Option) -> Command { - iced_sctk::commands::window::set_mode_window(id.unwrap_or(window::Id::MAIN), Mode::Hidden) -} - -/// Minimizes the window. -#[cfg(not(feature = "wayland"))] -pub fn minimize(id: Option) -> Command { - iced_runtime::window::minimize(id.unwrap_or(window::Id::MAIN), true) -} - -/// Sets the title of a window. -#[cfg(feature = "wayland")] -pub fn set_title(id: Option, title: String) -> Command { - window_action(WindowAction::Title { - id: id.unwrap_or(window::Id::MAIN), - title, - }) -} - -/// Sets the title of a window. -#[cfg(not(feature = "wayland"))] -#[allow(unused_variables, clippy::needless_pass_by_value)] -pub fn set_title(id: Option, title: String) -> Command { - Command::none() -} - -/// Sets the window mode to windowed. -#[cfg(feature = "wayland")] -pub fn set_windowed(id: Option) -> Command { - iced_sctk::commands::window::set_mode_window(id.unwrap_or(window::Id::MAIN), Mode::Windowed) -} - -/// Sets the window mode to windowed. -#[cfg(not(feature = "wayland"))] -pub fn set_windowed(id: Option) -> Command { - iced_runtime::window::change_mode(id.unwrap_or(window::Id::MAIN), Mode::Windowed) -} - -/// Toggles the windows' maximize state. -#[cfg(feature = "wayland")] -pub fn toggle_maximize(id: Option) -> Command { - iced_sctk::commands::window::toggle_maximize(id.unwrap_or(window::Id::MAIN)) -} - -/// Toggles the windows' maximize state. -#[cfg(not(feature = "wayland"))] -pub fn toggle_maximize(id: Option) -> Command { - iced_runtime::window::toggle_maximize(id.unwrap_or(window::Id::MAIN)) -} - -/// 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 index 14cf419e..9807961c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -6,20 +6,28 @@ use crate::cosmic_theme::Density; use cosmic_config::cosmic_config_derive::CosmicConfigEntry; use cosmic_config::{Config, CosmicConfigEntry}; -use iced::font::Family; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use std::sync::{LazyLock, RwLock}; -use ustr::Ustr; /// 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 { + 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 @@ -67,12 +75,12 @@ pub fn interface_density() -> Density { #[allow(clippy::missing_panics_doc)] pub fn interface_font() -> FontConfig { - COSMIC_TK.read().unwrap().interface_font + COSMIC_TK.read().unwrap().interface_font.clone() } #[allow(clippy::missing_panics_doc)] pub fn monospace_font() -> FontConfig { - COSMIC_TK.read().unwrap().monospace_font + COSMIC_TK.read().unwrap().monospace_font.clone() } #[derive(Clone, CosmicConfigEntry, Debug, Eq, PartialEq)] @@ -113,13 +121,13 @@ impl Default for CosmicTk { header_size: Density::Standard, interface_density: Density::Standard, interface_font: FontConfig { - family: Ustr::from("Fira Sans"), + 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: Ustr::from("Fira Mono"), + family: MONO_FAMILY_DEFAULT.to_owned(), weight: iced::font::Weight::Normal, stretch: iced::font::Stretch::Normal, style: iced::font::Style::Normal, @@ -129,14 +137,15 @@ impl Default for CosmicTk { } impl CosmicTk { + #[inline] pub fn config() -> Result { Config::new(ID, Self::VERSION) } } -#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub struct FontConfig { - pub family: Ustr, + pub family: String, pub weight: iced::font::Weight, pub stretch: iced::font::Stretch, pub style: iced::font::Style, @@ -144,8 +153,22 @@ pub struct FontConfig { 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(font.family.as_str()), + family: iced::font::Family::Name(name), weight: font.weight, stretch: font.stretch, style: font.style, diff --git a/src/app/core.rs b/src/core.rs similarity index 63% rename from src/app/core.rs rename to src/core.rs index eee5ae58..970a5351 100644 --- a/src/app/core.rs +++ b/src/core.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use crate::widget::nav_bar; use cosmic_config::CosmicConfigEntry; use cosmic_theme::ThemeMode; -use iced::window; +use iced::{Limits, Size, window}; use iced_core::window::Id; use palette::Srgba; use slotmap::Key; @@ -26,8 +26,6 @@ pub struct NavBar { #[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, @@ -40,8 +38,9 @@ pub struct Window { pub show_close: bool, pub show_maximize: bool, pub show_minimize: bool, - height: u32, - width: u32, + pub is_maximized: bool, + height: f32, + width: f32, } /// COSMIC-specific application settings @@ -66,7 +65,7 @@ pub struct Core { scale_factor: f32, /// Window focus state - pub(super) focused_window: Option, + pub(super) focused_window: Vec, pub(super) theme_sub_counter: u64, /// Last known system theme @@ -91,8 +90,17 @@ pub struct Core { #[cfg(feature = "single-instance")] pub(crate) single_instance: bool, - #[cfg(feature = "dbus-config")] + #[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 { @@ -115,7 +123,7 @@ impl Default for Core { system_theme_mode: ThemeMode::config() .map(|c| { ThemeMode::get_entry(&c).unwrap_or_else(|(errors, mode)| { - for why in errors { + for why in errors.into_iter().filter(cosmic_config::Error::is_err) { tracing::error!(?why, "ThemeMode config entry error"); } mode @@ -123,7 +131,6 @@ impl Default for Core { }) .unwrap_or_default(), window: Window { - context_title: String::new(), header_title: String::new(), use_template: true, content_container: true, @@ -135,19 +142,25 @@ impl Default for Core { show_maximize: true, show_minimize: true, show_window_menu: false, - height: 0, - width: 0, + is_maximized: false, + height: 0., + width: 0., }, - focused_window: Some(window::Id::MAIN), + focused_window: Vec::new(), #[cfg(feature = "applet")] applet: crate::applet::Context::default(), #[cfg(feature = "single-instance")] single_instance: false, - #[cfg(feature = "dbus-config")] + #[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, } } } @@ -155,50 +168,52 @@ impl Default for Core { impl Core { /// Whether the window is too small for the nav bar + main content. #[must_use] - pub fn is_condensed(&self) -> bool { + #[inline] + pub const fn is_condensed(&self) -> bool { self.is_condensed } /// The scaling factor used by the application. #[must_use] - pub fn scale_factor(&self) -> f32 { + #[inline] + pub const fn scale_factor(&self) -> f32 { self.scale_factor } /// Enable or disable keyboard navigation - pub fn set_keyboard_nav(&mut self, enabled: bool) { + #[inline] + pub const fn set_keyboard_nav(&mut self, enabled: bool) { self.keyboard_nav = enabled; } - #[must_use] /// Enable or disable keyboard navigation - pub fn keyboard_nav(&self) -> bool { + #[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 context drawer header title - pub fn set_context_title(&mut self, title: String) { - self.window.context_title = title; - } - /// 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 } - /// Call this whenever the scaling factor or window width has changed. #[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; @@ -207,10 +222,11 @@ impl Core { // Context drawer min width (344px) + padding (8px) breakpoint += 344.0 + 8.0; }; - self.is_condensed = (breakpoint * self.scale_factor) > self.window.width as f32; + 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 @@ -219,8 +235,9 @@ impl Core { && !self.window.context_is_overlay } + #[inline] pub(crate) fn context_width(&self, has_nav: bool) -> f32 { - let window_width = (self.window.width as f32) / self.scale_factor; + let window_width = self.window.width / self.scale_factor; // Content width (360px) + padding (8px) let mut reserved_width = 360.0 + 8.0; @@ -229,12 +246,14 @@ impl Core { 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(); @@ -245,34 +264,46 @@ impl Core { } } + #[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] - pub fn nav_bar_active(&self) -> bool { + #[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); } - pub(crate) fn nav_bar_context(&self) -> nav_bar::Id { + #[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(); @@ -288,6 +319,7 @@ impl Core { } } + #[inline] pub(crate) fn nav_bar_update(&mut self) { self.nav_bar.active = if self.is_condensed { self.nav_bar.toggled_condensed @@ -296,25 +328,29 @@ impl Core { }; } + #[inline] /// Set the height of the main window. - pub(crate) fn set_window_height(&mut self, new_height: u32) { + 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: u32) { + 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 fn system_theme(&self) -> &Theme { + pub const fn system_theme(&self) -> &Theme { &self.system_theme } + #[inline] #[must_use] /// Get the current system theme mode - pub fn system_theme_mode(&self) -> ThemeMode { + pub const fn system_theme_mode(&self) -> ThemeMode { self.system_theme_mode } @@ -324,9 +360,13 @@ impl Core { &self, config_id: &'static str, ) -> iced::Subscription> { - #[cfg(feature = "dbus-config")] - if let Some(settings_daemon) = self.settings_daemon.clone() { - return cosmic_config::dbus::watcher_subscription(settings_daemon, config_id, false); + #[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::(), @@ -341,9 +381,13 @@ impl Core { &self, state_id: &'static str, ) -> iced::Subscription> { - #[cfg(feature = "dbus-config")] - if let Some(settings_daemon) = self.settings_daemon.clone() { - return cosmic_config::dbus::watcher_subscription(settings_daemon, state_id, true); + #[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::(), @@ -354,14 +398,108 @@ impl Core { /// Get the current focused window if it exists #[must_use] + #[inline] pub fn focused_window(&self) -> Option { - self.focused_window.clone() + 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 index 21b50aca..98ce7d4b 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -1,186 +1,720 @@ -pub use freedesktop_desktop_entry::DesktopEntry; +#[cfg(not(windows))] +pub use freedesktop_desktop_entry as fde; +#[cfg(not(windows))] pub use mime::Mime; -use std::{ - borrow::Cow, - ffi::OsStr, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; +#[cfg(not(windows))] +use std::{borrow::Cow, collections::HashSet, ffi::OsStr}; -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum IconSource { - Name(String), - Path(PathBuf), +pub trait IconSourceExt { + fn as_cosmic_icon(&self) -> crate::widget::icon::Handle; } -impl IconSource { - pub fn from_unknown(icon: &str) -> Self { - let icon_path = Path::new(icon); - if icon_path.is_absolute() && icon_path.exists() { - Self::Path(icon_path.into()) - } else { - Self::Name(icon.into()) - } - } - - pub fn as_cosmic_icon(&self) -> crate::widget::icon::Icon { +#[cfg(not(windows))] +impl IconSourceExt for fde::IconSource { + fn as_cosmic_icon(&self) -> crate::widget::icon::Handle { match self { - Self::Name(name) => crate::widget::icon::from_name(name.as_str()) + 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(), ]))) - .into(), - Self::Path(path) => crate::widget::icon(crate::widget::icon::from_path(path.clone())), + .handle(), + fde::IconSource::Path(path) => crate::widget::icon::from_path(path.clone()), } } } -impl Default for IconSource { - fn default() -> Self { - Self::Name("application-default".to_string()) - } -} - +#[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: IconSource, + 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, } -pub fn load_applications<'a>( - locale: impl Into>, - include_no_display: bool, -) -> Vec { - load_applications_filtered(locale, |de| include_no_display || !de.no_display()) +#[cfg(not(windows))] +#[derive(Debug, Clone)] +pub struct DesktopEntryCache { + locales: Vec, + entries: Vec, } -pub fn app_id_or_fallback_matches(app_id: &str, entry: &DesktopEntryData) -> bool { - let lowercase_wm_class = match entry.wm_class.as_ref() { - Some(s) => Some(s.to_lowercase()), - None => None, - }; - - app_id == entry.id - || Some(app_id.to_lowercase()) == lowercase_wm_class - || app_id.to_lowercase() == entry.name.to_lowercase() -} - -pub fn load_applications_for_app_ids<'a, 'b>( - locale: impl Into>, - app_ids: impl Iterator, - fill_missing_ones: bool, - include_no_display: bool, -) -> Vec { - let mut app_ids = app_ids.collect::>(); - let mut applications = load_applications_filtered(locale, |de| { - if !include_no_display && de.no_display() { - return false; +#[cfg(not(windows))] +impl DesktopEntryCache { + pub fn new(locales: Vec) -> Self { + Self { + locales, + entries: Vec::new(), } - // If appid matches, or startup_wm_class matches... - if let Some(i) = app_ids.iter().position(|id| { - id == &de.appid - || id - .to_lowercase() - .eq(&de.startup_wm_class().unwrap_or_default().to_lowercase()) - }) { - app_ids.remove(i); - true - // Fallback: If the name matches... - } else if let Some(i) = app_ids.iter().position(|id| { - de.name(None) - .map(|n| n.to_lowercase() == id.to_lowercase()) - .unwrap_or_default() - }) { - app_ids.remove(i); - true - } else { - false - } - }); - if fill_missing_ones { - applications.extend(app_ids.into_iter().map(|app_id| DesktopEntryData { - id: app_id.to_string(), - name: app_id.to_string(), - icon: IconSource::default(), - ..Default::default() - })); } - applications + + 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 + } } -pub fn load_applications_filtered<'a, F: FnMut(&DesktopEntry) -> bool>( - locale: impl Into>, - mut filter: F, -) -> Vec { - let locale = locale.into(); - - freedesktop_desktop_entry::Iter::new(freedesktop_desktop_entry::default_paths()) - .filter_map(|path| { - std::fs::read_to_string(&path).ok().and_then(|input| { - DesktopEntry::decode(&path, &input).ok().and_then(|de| { - if !filter(&de) { - return None; - } - - Some(DesktopEntryData::from_desktop_entry( - locale, - path.clone(), - de, - )) - }) - }) - }) - .collect() +#[cfg(not(windows))] +impl Default for DesktopEntryCache { + fn default() -> Self { + Self::new(Vec::new()) + } } -pub fn load_desktop_file<'a>( - locale: impl Into>, - path: impl AsRef, -) -> Option { - let path = path.as_ref(); - std::fs::read_to_string(path).ok().and_then(|input| { - DesktopEntry::decode(path, &input) - .ok() - .map(|de| DesktopEntryData::from_desktop_entry(locale, PathBuf::from(path), de)) +#[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() }) } -impl DesktopEntryData { - fn from_desktop_entry<'a>( - locale: impl Into>, - path: impl Into>, - de: DesktopEntry, - ) -> DesktopEntryData { - let locale = locale.into(); +#[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(locale) - .unwrap_or(Cow::Borrowed(de.appid)) + .name(locales) + .unwrap_or(Cow::Borrowed(&de.appid)) .to_string(); // check if absolute path exists and otherwise treat it as a name - let icon = de.icon().unwrap_or(de.appid); - let icon_path = Path::new(icon); - let icon = if icon_path.is_absolute() && icon_path.exists() { - IconSource::Path(icon_path.into()) - } else { - IconSource::Name(icon.into()) - }; + let icon = fde::IconSource::from_unknown(de.icon().unwrap_or(&de.appid)); DesktopEntryData { id: de.appid.to_string(), @@ -188,20 +722,19 @@ impl DesktopEntryData { exec: de.exec().map(ToString::to_string), name, icon, - path: path.into(), categories: de .categories() .unwrap_or_default() - .split_terminator(';') + .into_iter() .map(std::string::ToString::to_string) .collect(), desktop_actions: de .actions() .map(|actions| { actions - .split(';') + .into_iter() .filter_map(|action| { - let name = de.action_entry_localized(action, "Name", locale); + 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 { @@ -219,24 +752,50 @@ impl DesktopEntryData { .mime_type() .map(|mime_types| { mime_types - .split_terminator(';') + .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), } } } -pub async fn spawn_desktop_exec(exec: S, env_vars: I, app_id: Option<&str>) -where +#[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 mut exec = shlex::Shlex::new(exec.as_ref()); + 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, @@ -293,6 +852,7 @@ where } } +#[cfg(not(windows))] #[cfg(feature = "desktop-systemd-scope")] #[zbus::proxy( interface = "org.freedesktop.systemd1.Manager", @@ -308,3 +868,275 @@ trait SystemdManger { 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 0f328e32..186f7625 100644 --- a/src/dialog/file_chooser/mod.rs +++ b/src/dialog/file_chooser/mod.rs @@ -11,7 +11,7 @@ //! # Open a file //! //! ```no_run -//! cosmic::command::future(async { +//! cosmic::task::future(async { //! use cosmic::dialog::file_chooser; //! //! let dialog = file_chooser::open::Dialog::new() @@ -30,7 +30,7 @@ //! # Open multiple files //! //! ```no_run -//! cosmic::command::future(async { +//! cosmic::task::future(async { //! use cosmic::dialog::file_chooser; //! //! let dialog = file_chooser::open::Dialog::new() @@ -49,7 +49,7 @@ //! # Open a folder //! //! ```no_run -//! cosmic::command::future(async { +//! cosmic::task::future(async { //! use cosmic::dialog::file_chooser; //! //! let dialog = file_chooser::open::Dialog::new() @@ -68,7 +68,7 @@ //! # Open multiple folders //! //! ```no_run -//! cosmic::command::future(async { +//! cosmic::task::future(async { //! use cosmic::dialog::file_chooser; //! //! let dialog = file_chooser::open::Dialog::new() diff --git a/src/dialog/file_chooser/open.rs b/src/dialog/file_chooser/open.rs index 80e5ffbe..f24afda9 100644 --- a/src/dialog/file_chooser/open.rs +++ b/src/dialog/file_chooser/open.rs @@ -7,10 +7,10 @@ //! example in our repository. #[cfg(feature = "xdg-portal")] -pub use portal::{file, files, folder, folders, FileResponse, MultiFileResponse}; +pub use portal::{FileResponse, MultiFileResponse, file, files, folder, folders}; #[cfg(feature = "rfd")] -pub use rust_fd::{file, files, folder, folders, FileResponse, MultiFileResponse}; +pub use rust_fd::{FileResponse, MultiFileResponse, file, files, folder, folders}; use super::Error; use std::path::PathBuf; diff --git a/src/dialog/file_chooser/save.rs b/src/dialog/file_chooser/save.rs index 63c07340..d7a2a34e 100644 --- a/src/dialog/file_chooser/save.rs +++ b/src/dialog/file_chooser/save.rs @@ -7,10 +7,10 @@ //! example in our repository. #[cfg(feature = "xdg-portal")] -pub use portal::{file, Response}; +pub use portal::{Response, file}; #[cfg(feature = "rfd")] -pub use rust_fd::{file, Response}; +pub use rust_fd::{Response, file}; use super::Error; use std::path::PathBuf; @@ -120,6 +120,12 @@ impl Dialog { } } +impl Default for Dialog { + fn default() -> Self { + Self::new() + } +} + #[cfg(feature = "xdg-portal")] mod portal { use super::Dialog; 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 beb18c56..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,72 +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 fae63d2e..e0eb4745 100644 --- a/src/font.rs +++ b/src/font.rs @@ -4,12 +4,14 @@ //! Select preferred fonts. pub use iced::Font; -use iced_core::font::{Family, Weight}; +use iced_core::font::Weight; +#[inline] pub fn default() -> Font { Font::from(crate::config::interface_font()) } +#[inline] pub fn light() -> Font { Font { weight: Weight::Light, @@ -17,6 +19,7 @@ pub fn light() -> Font { } } +#[inline] pub fn semibold() -> Font { Font { weight: Weight::Semibold, @@ -24,6 +27,7 @@ pub fn semibold() -> Font { } } +#[inline] pub fn bold() -> Font { Font { weight: Weight::Bold, @@ -31,6 +35,7 @@ pub fn bold() -> Font { } } +#[inline] pub fn mono() -> Font { Font::from(crate::config::monospace_font()) } diff --git a/src/icon_theme.rs b/src/icon_theme.rs index 277d2cff..69fe5841 100644 --- a/src/icon_theme.rs +++ b/src/icon_theme.rs @@ -13,12 +13,14 @@ pub(crate) static DEFAULT: Mutex> = Mutex::new(Cow::Borrowed(C /// 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.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.lock().unwrap() = name.into(); } diff --git a/src/keyboard_nav.rs b/src/keyboard_nav.rs index ae4c0f9d..961a423b 100644 --- a/src/keyboard_nav.rs +++ b/src/keyboard_nav.rs @@ -3,12 +3,12 @@ //! Subscribe to common application keyboard shortcuts. -use iced::{event, keyboard, Event, Subscription}; +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, @@ -16,8 +16,9 @@ pub enum Message { Search, } -pub fn subscription() -> Subscription { - listen_raw(|event, status| { +#[cold] +pub fn subscription() -> Subscription { + listen_raw(|event, status, _| { if event::Status::Ignored != status { return None; } @@ -28,20 +29,20 @@ pub fn subscription() -> Subscription { modifiers, .. }) => match key { - Named::Tab => { + Named::Tab if !modifiers.control() => { return Some(if modifiers.shift() { - Message::FocusPrevious + Action::FocusPrevious } else { - Message::FocusNext + Action::FocusNext }); } Named::Escape => { - return Some(Message::Escape); + return Some(Action::Escape); } Named::F11 => { - return Some(Message::Fullscreen); + return Some(Action::Fullscreen); } _ => (), @@ -51,7 +52,7 @@ pub fn subscription() -> Subscription { modifiers, .. }) if c == "f" && modifiers.control() => { - return Some(Message::Search); + return Some(Action::Search); } _ => (), diff --git a/src/lib.rs b/src/lib.rs index 9d4b6e8f..02623799 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,31 +3,40 @@ #![allow(clippy::module_name_repetitions)] #![cfg_attr(target_os = "redox", feature(lazy_cell))] - -#[cfg(all(feature = "wayland", feature = "winit"))] -compile_error!("cannot use `wayland` feature with `winit`"); +#![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)] @@ -36,6 +45,14 @@ pub use cosmic_config; #[doc(inline)] pub use cosmic_theme; +#[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; @@ -50,53 +67,35 @@ pub mod font; #[doc(inline)] pub use iced; -#[doc(inline)] -pub use iced_core; - -#[doc(inline)] -pub use iced_futures; - -#[doc(inline)] -pub use iced_renderer; - -#[doc(inline)] -pub use iced_runtime; - -#[cfg(feature = "wayland")] -pub use iced_sctk; - -#[doc(inline)] -pub use iced_style; - -#[doc(inline)] -pub use iced_widget; - -#[doc(inline)] -#[cfg(feature = "winit")] -pub use iced_winit; - -#[doc(inline)] -#[cfg(feature = "wgpu")] -pub use iced_wgpu; - pub mod icon_theme; pub mod keyboard_nav; -#[cfg(feature = "desktop")] -pub mod desktop; -#[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 mod scroll; + #[doc(inline)] -pub use theme::{style, Theme}; +pub use theme::{Theme, style}; pub mod widget; - +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 efbfdd10..2b6c4e0e 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,37 +1,59 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + #[cfg(all(feature = "smol", not(feature = "tokio")))] use smol::io::AsyncReadExt; -use std::fs::File; use std::io; use std::os::fd::OwnedFd; -use std::process::{exit, Command, Stdio}; +use std::process::{Command, Stdio, exit}; #[cfg(feature = "tokio")] use tokio::io::AsyncReadExt; -#[cfg(feature = "tokio")] async fn read_from_pipe(read: OwnedFd) -> Option { - let mut read = tokio::net::unix::pipe::Receiver::from_owned_fd(read).unwrap(); - read.read_u32().await.ok() -} + #[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")))] -async fn read_from_pipe(read: OwnedFd) -> Option { - let mut read = smol::Async::new(std::fs::File::from(read)).unwrap(); - let mut bytes = [0; 4]; - read.read_exact(&mut bytes).await.ok()?; - Some(u32::from_be_bytes(bytes)) + #[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. +#[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()); + // 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.. => { 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 292c0dcc..093bac05 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -6,44 +6,46 @@ #[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 std::sync::{Arc, Mutex}; - -#[cfg(feature = "dbus-config")] -use cosmic_config::dbus; +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; -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); + +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, @@ -51,52 +53,66 @@ pub(crate) static THEME: Mutex = Mutex::new(Theme { }); /// Currently-defined theme. +#[inline] #[allow(clippy::missing_panics_doc)] pub fn active() -> Theme { THEME.lock().unwrap().clone() } /// Currently-defined theme type. +#[inline] #[allow(clippy::missing_panics_doc)] pub fn active_type() -> ThemeType { 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(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 err in res.errors { - tracing::error!("{:?}", err); - } +// /// 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(res.config)) - }) -} +// Theme::system(Arc::new(res.config)) +// }) +// } pub fn system_dark() -> Theme { let Ok(helper) = crate::cosmic_theme::Theme::dark_config() else { @@ -104,8 +120,8 @@ pub fn system_dark() -> Theme { }; let t = crate::cosmic_theme::Theme::get_entry(&helper).unwrap_or_else(|(errors, theme)| { - for err in errors { - tracing::error!("{:?}", err); + for error in errors.into_iter().filter(cosmic_config::Error::is_err) { + tracing::error!(?error, "error loading system dark theme"); } theme }); @@ -119,8 +135,8 @@ pub fn system_light() -> Theme { }; let t = crate::cosmic_theme::Theme::get_entry(&helper).unwrap_or_else(|(errors, theme)| { - for err in errors { - tracing::error!("{:?}", err); + for error in errors.into_iter().filter(cosmic_config::Error::is_err) { + tracing::error!(?error, "error loading system light theme"); } theme }); @@ -162,6 +178,7 @@ pub enum ThemeType { 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, @@ -171,6 +188,7 @@ impl ThemeType { } /// Whether the theme has a high contrast. + #[inline] #[must_use] pub fn is_high_contrast(&self) -> bool { match self { @@ -180,6 +198,7 @@ impl ThemeType { } } + #[inline] /// Prefer dark or light theme. /// If `None`, the system preference is used. pub fn prefer_dark(&mut self, new_prefer_dark: Option) { @@ -197,6 +216,7 @@ pub struct Theme { } impl Theme { + #[inline] pub fn cosmic(&self) -> &cosmic_theme::Theme { match self.theme_type { ThemeType::Dark => &COSMIC_DARK, @@ -207,6 +227,7 @@ impl Theme { } } + #[inline] pub fn dark() -> Self { Self { theme_type: ThemeType::Dark, @@ -214,6 +235,7 @@ impl Theme { } } + #[inline] pub fn light() -> Self { Self { theme_type: ThemeType::Light, @@ -221,6 +243,7 @@ impl Theme { } } + #[inline] pub fn dark_hc() -> Self { Self { theme_type: ThemeType::HighContrastDark, @@ -228,6 +251,7 @@ impl Theme { } } + #[inline] pub fn light_hc() -> Self { Self { theme_type: ThemeType::HighContrastLight, @@ -235,6 +259,7 @@ impl Theme { } } + #[inline] pub fn custom(theme: Arc) -> Self { Self { theme_type: ThemeType::Custom(theme), @@ -242,6 +267,7 @@ impl Theme { } } + #[inline] pub fn system(theme: Arc) -> Self { Self { theme_type: ThemeType::System { @@ -252,6 +278,7 @@ impl Theme { } } + #[inline] /// get current container /// can be used in a component that is intended to be a child of a `CosmicContainer` pub fn current_container(&self) -> &cosmic_theme::Container { @@ -262,6 +289,7 @@ impl Theme { } } + #[inline] /// set the theme pub fn set_theme(&mut self, theme: ThemeType) { self.theme_type = theme; @@ -269,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 index b0fc5f84..0154ff58 100644 --- a/src/theme/portal.rs +++ b/src/theme/portal.rs @@ -1,7 +1,7 @@ -use ashpd::desktop::settings::{ColorScheme, Contrast}; use ashpd::desktop::Color; -use iced::futures::{self, select, FutureExt, SinkExt, StreamExt}; -use iced_futures::subscription; +use ashpd::desktop::settings::{ColorScheme, Contrast}; +use iced::futures::{self, FutureExt, SinkExt, StreamExt, select}; +use iced_futures::stream; use tracing::error; #[derive(Debug, Clone)] @@ -11,87 +11,93 @@ pub enum Desktop { Contrast(Contrast), } +#[cold] pub fn desktop_settings() -> iced_futures::Subscription { - subscription::channel(std::any::TypeId::of::(), 10, |mut tx| { - 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"))] - { - 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"); - } - + iced_futures::Subscription::run(|| { + stream::channel(10, |mut tx: futures::channel::mpsc::Sender| { + async move { + let mut attempts = 0; loop { - if color_scheme_stream.is_none() && contrast_stream.is_none() { - break; + 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 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 - }; + let mut contrast_stream = settings.receive_contrast_changed().await.ok(); + if contrast_stream.is_none() { + error!("Failed to receive contrast changes"); + } - select! { - s = next_color_scheme.fuse() => { - if let Some(s) = s { - _ = tx.send(Desktop::ColorScheme(s)).await; - } else { - color_scheme_stream = None; + 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 + }; - c = next_contrast.fuse() => { - if let Some(c) = c { - _ = tx.send(Desktop::Contrast(c)).await; - } else { - contrast_stream = None; + let next_contrast = async { + if let Some(s) = contrast_stream.as_mut() { + return s.next().await; } - } - }; - // Reset the attempts counter if we successfully received a change - attempts = 0; + 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 c2dc6558..bb52d9a6 100644 --- a/src/theme/style/button.rs +++ b/src/theme/style/button.rs @@ -8,28 +8,30 @@ use iced_core::{Background, Color}; use crate::{ theme::TRANSPARENT_COMPONENT, - widget::button::{Appearance, StyleSheet}, + widget::button::{Catalog, Style}, }; #[derive(Default)] pub enum Button { AppletIcon, - Custom { - active: Box Appearance>, - disabled: Box Appearance>, - hovered: Box Appearance>, - pressed: Box Appearance>, - }, AppletMenu, + Custom { + active: Box Style>, + disabled: Box Style>, + hovered: Box Style>, + pressed: Box Style>, + }, Destructive, HeaderBar, Icon, IconVertical, Image, Link, + ListItem([f32; 4]), MenuFolder, MenuItem, MenuRoot, + NavToggle, #[default] Standard, Suggested, @@ -44,11 +46,11 @@ pub fn appearance( disabled: bool, style: &Button, color: impl Fn(&Component) -> (Color, Option, Option), -) -> Appearance { +) -> 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 @@ -69,10 +71,13 @@ pub fn appearance( 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::HeaderBar => { + Button::Icon | Button::IconVertical | Button::HeaderBar | Button::NavToggle => { if matches!(style, Button::IconVertical) { corner_radii = &cosmic.corner_radii.radius_m; if selected { @@ -81,6 +86,9 @@ pub fn appearance( ))); } } + 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)); @@ -91,7 +99,7 @@ pub fn appearance( 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; @@ -100,6 +108,9 @@ pub fn appearance( 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; @@ -107,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; } @@ -137,6 +148,21 @@ pub fn appearance( 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, text, icon) = color(&cosmic.background.component); appearance.background = Some(Background::Color(background)); @@ -163,30 +189,38 @@ 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, selected: 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, selected, false, style, move |component| { + 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_color().into()) + 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); } @@ -202,16 +236,16 @@ impl StyleSheet for crate::Theme { }) } - fn drop_target(&self, style: &Self::Style) -> Appearance { + fn drop_target(&self, style: &Self::Class) -> Style { self.active(false, false, style) } - fn hovered(&self, focused: bool, selected: 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, @@ -223,17 +257,25 @@ impl StyleSheet for crate::Theme { Button::Icon | Button::IconVertical | Button::HeaderBar ) && selected { - Some(self.cosmic().accent_color().into()) + 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, selected: 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); } @@ -244,7 +286,7 @@ impl StyleSheet for crate::Theme { Button::Icon | Button::IconVertical | Button::HeaderBar ) && selected { - Some(self.cosmic().accent_color().into()) + Some(self.cosmic().accent_text_color().into()) } else { Some(component.on.into()) }; diff --git a/src/theme/style/dropdown.rs b/src/theme/style/dropdown.rs index f62ab984..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 { @@ -21,7 +21,7 @@ impl dropdown::menu::StyleSheet for Theme { 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 9b4095e7..aa6f4b33 100644 --- a/src/theme/style/iced.rs +++ b/src/theme/style/iced.rs @@ -3,54 +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::{ + 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_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 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(), } } } @@ -69,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, @@ -86,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, @@ -96,86 +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(), - }, - border: Border { - radius: match style { - Button::Link => corner_radii.radius_0.into(), - Button::Card => corner_radii.radius_xs.into(), - _ => corner_radii.radius_xl.into(), - }, - ..Default::default() - }, - 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 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: 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 */ @@ -193,170 +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: Border { - radius: corners.radius_xs.into(), - width: if is_checked { 0.0 } else { 1.0 }, - 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: Border { - radius: corners.radius_xs.into(), - width: if is_checked { 0.0 } else { 1.0 }, - 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: Border { - radius: corners.radius_xs.into(), - width: if is_checked { 0.0 } else { 1.0 }, - 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: Border { - radius: corners.radius_xs.into(), - width: if is_checked { 0.0 } else { 1.0 }, - 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: Border { - radius: corners.radius_xs.into(), - width: if is_checked { 0.0 } else { 1.0 }, - 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); + } } - .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: 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.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: Border { - radius: corners.radius_xs.into(), - width: if is_checked { 0.0 } else { 1.0 }, - 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: Border { - radius: corners.radius_xs.into(), - width: if is_checked { 0.0 } else { 1.0 }, - color: if is_checked { - cosmic.destructive.base - } else { - cosmic.button.border - } - .into(), - }, - text_color: None, - }, + if let Some(c) = active.text_color.as_mut() { + c.a /= 2. + }; + active.border.color.a /= 2.; + } + 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, + }, + } + } } } } @@ -365,16 +386,18 @@ impl checkbox::StyleSheet for Theme { * TODO: Container */ #[derive(Default)] -pub enum Container { +pub enum Container<'a> { WindowBackground, Background, Card, ContextDrawer, - Custom(Box container::Appearance>), + Custom(Box iced_container::Style + 'a>), Dialog, Dropdown, HeaderBar { focused: bool, + sharp_corners: bool, + transparent: bool, }, List, Primary, @@ -384,67 +407,82 @@ 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) -> container::Appearance { - container::Appearance { + 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_xs.into(), + radius: theme.corner_radii.radius_s.into(), ..Default::default() }, shadow: Shadow::default(), + snap: true, } } #[must_use] - pub fn primary(theme: &cosmic_theme::Theme) -> container::Appearance { - container::Appearance { + 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_xs.into(), + radius: theme.corner_radii.radius_s.into(), ..Default::default() }, shadow: Shadow::default(), + snap: true, } } #[must_use] - pub fn secondary(theme: &cosmic_theme::Theme) -> container::Appearance { - container::Appearance { + 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_xs.into(), + 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) + } +} - #[allow(clippy::too_many_lines)] - fn appearance(&self, style: &Self::Style) -> container::Appearance { +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(); - match style { - Container::Transparent => container::Appearance::default(), + // 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(), Container::Custom(f) => f(self), - Container::WindowBackground => container::Appearance { + 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())), @@ -452,18 +490,19 @@ impl container::StyleSheet for Theme { radius: [ cosmic.corner_radii.radius_0[0], cosmic.corner_radii.radius_0[1], - cosmic.corner_radii.radius_s[2], - cosmic.corner_radii.radius_s[3], + window_corner_radius[2], + window_corner_radius[3], ] .into(), ..Default::default() }, shadow: Shadow::default(), + snap: true, }, Container::List => { let component = &self.current_container().component; - container::Appearance { + iced_container::Style { icon_color: Some(component.on.into()), text_color: Some(component.on.into()), background: Some(Background::Color(component.base.into())), @@ -472,13 +511,18 @@ impl container::StyleSheet for Theme { ..Default::default() }, shadow: Shadow::default(), + snap: true, } } - Container::HeaderBar { focused } => { + Container::HeaderBar { + focused, + sharp_corners, + transparent, + } => { let (icon_color, text_color) = if *focused { ( - Color::from(cosmic.accent.base), + Color::from(cosmic.accent_text_color()), Color::from(cosmic.background.on), ) } else { @@ -488,40 +532,45 @@ impl container::StyleSheet for Theme { (unfocused_color, unfocused_color) }; - container::Appearance { + iced_container::Style { icon_color: Some(icon_color), text_color: Some(text_color), - background: Some(iced::Background::Color(cosmic.background.base.into())), + background: if *transparent { + None + } else { + Some(iced::Background::Color(cosmic.background.base.into())) + }, border: Border { radius: [ - cosmic.corner_radii.radius_s[0], - cosmic.corner_radii.radius_s[1], + 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::ContextDrawer => { - let mut appearance = crate::style::Container::primary(cosmic); + let mut a = Container::primary(cosmic); - appearance.border = Border { - color: cosmic.primary.divider.into(), - width: 0.0, - radius: cosmic.corner_radii.radius_s.into(), - }; - - appearance.shadow = Shadow { - color: cosmic.shade.into(), - offset: Vector::new(0.0, 0.0), - blur_radius: 16.0, - }; - - appearance + if cosmic.is_high_contrast { + a.border.width = 1.; + a.border.color = cosmic.primary.divider.into(); + } + a } Container::Background => Container::background(cosmic), @@ -530,22 +579,20 @@ impl container::StyleSheet for Theme { Container::Secondary => Container::secondary(cosmic), - Container::Dropdown => { - let theme = self.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::Appearance { - icon_color: None, - text_color: None, - background: Some(iced::Background::Color(theme.primary.base.into())), - border: Border { - radius: cosmic.corner_radii.radius_xs.into(), - ..Default::default() - }, - shadow: Shadow::default(), - } - } - - Container::Tooltip => container::Appearance { + Container::Tooltip => iced_container::Style { icon_color: None, text_color: None, background: Some(iced::Background::Color(cosmic.palette.neutral_2.into())), @@ -554,13 +601,14 @@ impl container::StyleSheet for Theme { ..Default::default() }, shadow: Shadow::default(), + snap: true, }, Container::Card => { let cosmic = self.cosmic(); match self.layer { - cosmic_theme::Layer::Background => container::Appearance { + 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( @@ -571,8 +619,9 @@ impl container::StyleSheet for Theme { ..Default::default() }, shadow: Shadow::default(), + snap: true, }, - cosmic_theme::Layer::Primary => container::Appearance { + 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( @@ -583,8 +632,9 @@ impl container::StyleSheet for Theme { ..Default::default() }, shadow: Shadow::default(), + snap: true, }, - cosmic_theme::Layer::Secondary => container::Appearance { + 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( @@ -595,11 +645,12 @@ impl container::StyleSheet for Theme { ..Default::default() }, shadow: Shadow::default(), + snap: true, }, } } - Container::Dialog => container::Appearance { + 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())), @@ -613,6 +664,7 @@ impl container::StyleSheet for Theme { offset: Vector::new(0.0, 4.0), blur_radius: 16.0, }, + snap: true, }, } } @@ -623,33 +675,59 @@ 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(), - cosmic.palette.neutral_6.into(), + 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: cosmic.corner_radii.radius_xs.into(), }, handle: slider::Handle { @@ -658,9 +736,9 @@ impl slider::StyleSheet for Theme { width: 20, border_radius: cosmic.corner_radii.radius_m.into(), }, - color: cosmic.accent.base.into(), border_color: Color::TRANSPARENT, border_width: 0.0, + background: Background::Color(cosmic.accent.base.into()), }, breakpoint: slider::Breakpoint { @@ -669,95 +747,104 @@ impl slider::StyleSheet for Theme { } } Slider::Custom { active, .. } => active(self), - } - } - - fn hovered(&self, style: &Self::Style) -> slider::Appearance { - match style { - Slider::Standard => { - let cosmic: &cosmic_theme::Theme = self.cosmic(); - - let mut style = self.active(style); - style.handle.shape = slider::HandleShape::Rectangle { - height: 26, - width: 26, - border_radius: cosmic.corner_radii.radius_m.into(), - }; - style.handle.border_width = 3.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: Border { radius: cosmic.corner_radii.radius_m.into(), ..Default::default() }, - selected_text_color: cosmic.accent.base.into(), + 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: Border { radius: cosmic.corner_radii.radius_m.into(), - ..Default::default() + 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, } } } @@ -765,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, + } + } } } } @@ -818,44 +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(); + fn default<'a>() -> Self::Class<'a> {} + + fn style(&self, class: &Self::Class<'_>, status: toggler::Status) -> toggler::Style { + let cosmic = self.cosmic(); const HANDLE_MARGIN: f32 = 2.0; - toggler::Appearance { - background: if is_active { - theme.accent.base.into() + let neutral_10 = cosmic.palette.neutral_10.with_alpha(0.1); + + 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 { - theme.palette.neutral_5.into() + cosmic.palette.neutral_5.into() }, - background_border: None, - foreground: theme.palette.neutral_2.into(), - foreground_border: None, - border_radius: theme.radius_xl().into(), - handle_radius: theme + 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, - } - } - - fn hovered(&self, style: &Self::Style, is_active: bool) -> toggler::Appearance { - let cosmic = self.cosmic(); - //TODO: grab colors from palette - let mut neutral_10 = cosmic.palette.neutral_10; - neutral_10.alpha = 0.1; - - toggler::Appearance { - background: if is_active { - over(neutral_10, cosmic.accent_color()) - } else { - over(neutral_10, cosmic.palette.neutral_5) + 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) } } } @@ -863,35 +970,30 @@ 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: Border { - radius: theme.corner_radii.radius_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, - color: theme.bg_divider().into(), }, } } @@ -906,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: theme.corner_radii.radius_xs.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: theme.corner_radii.radius_xs.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: theme.corner_radii.radius_xs.into(), + border, }, ProgressBar::Custom(f) => f(self), } @@ -951,37 +1082,41 @@ 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(8), + snap: true, }, - Rule::HeavyDivider => rule::Appearance { + Rule::HeavyDivider => rule::Style { color: self.current_container().divider.into(), - width: 4, radius: 2.0.into(), fill_mode: rule::FillMode::Full, + snap: true, }, Rule::Custom(f) => f(self), } @@ -998,99 +1133,182 @@ pub enum Scrollable { /* * TODO: Scrollable */ -impl scrollable::StyleSheet for Theme { - type Style = Scrollable; +impl scrollable::Catalog for Theme { + type Class<'a> = Scrollable; - fn active(&self, style: &Self::Style) -> scrollable::Scrollbar { - let cosmic = self.cosmic(); - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.7; - let mut a = scrollable::Scrollbar { - background: None, - border: Border { - radius: cosmic.corner_radii.radius_s.into(), - ..Default::default() - }, - scroller: scrollable::Scroller { - color: neutral_5.into(), - border: Border { - radius: cosmic.corner_radii.radius_s.into(), - ..Default::default() - }, - }, - }; - - if matches!(style, Scrollable::Permanent) { - let mut neutral_3 = cosmic.palette.neutral_3; - neutral_3.alpha = 0.7; - a.background = Some(Background::Color(neutral_3.into())); - } - - a + fn default<'a>() -> Self::Class<'a> { + Scrollable::default() } - fn hovered(&self, style: &Self::Style, is_mouse_over_scrollbar: bool) -> scrollable::Scrollbar { - let cosmic = self.cosmic(); - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.7; + 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); - if is_mouse_over_scrollbar { - let mut hover_overlay = cosmic.palette.neutral_0; - hover_overlay.alpha = 0.2; - neutral_5 = over(hover_overlay, neutral_5); - } - let mut a = scrollable::Scrollbar { - background: None, - border: Border { - radius: cosmic.corner_radii.radius_s.into(), - ..Default::default() - }, - scroller: scrollable::Scroller { - color: neutral_5.into(), - border: Border { - radius: cosmic.corner_radii.radius_s.into(), - ..Default::default() - }, - }, - }; - if matches!(style, Scrollable::Permanent) { - let mut neutral_3 = cosmic.palette.neutral_3; - neutral_3.alpha = 0.7; - a.background = Some(Background::Color(neutral_3.into())); - } + 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 + 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 + } + } } } #[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 { - #[allow(clippy::match_same_arms)] - match style { - Svg::Default => svg::Appearance::default(), - Svg::Custom(appearance) => appearance(self), - } + fn default<'a>() -> Self::Class<'a> { + Svg::default() } - fn hovered(&self, style: &Self::Style) -> svg::Appearance { - self.appearance(style) + fn style(&self, class: &Self::Class<'_>, status: svg::Status) -> svg::Style { + #[allow(clippy::match_same_arms)] + match class { + Svg::Default => svg::Style::default(), + Svg::Custom(appearance) => appearance(self), + } } } @@ -1104,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 { @@ -1113,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), } } @@ -1138,221 +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: Border { radius: palette.corner_radii.radius_s.into(), width: 1.0, color: self.current_container().component.divider.into(), }, - icon_color: self.current_container().on.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: Border { radius: palette.corner_radii.radius_m.into(), ..Default::default() }, - icon_color: self.current_container().on.into(), + icon: self.current_container().on.into(), + placeholder, + value, + selection, }, - } - } + }; - 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 status { + text_input::Status::Active => appearance, + text_input::Status::Hovered => { + let bg = self.current_container().small_widget.with_alpha(0.25); - match style { - TextInput::Default => text_input::Appearance { - background: Color::from(bg).into(), - border: Border { - radius: palette.corner_radii.radius_s.into(), - width: 1.0, - color: self.current_container().on.into(), - }, - icon_color: self.current_container().on.into(), - }, - TextInput::Search => text_input::Appearance { - background: Color::from(bg).into(), - border: Border { - radius: palette.corner_radii.radius_m.into(), - ..Default::default() - }, - icon_color: self.current_container().on.into(), - }, - } - } + 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); - 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: Border { - radius: palette.corner_radii.radius_s.into(), - width: 1.0, - color: palette.accent.base.into(), - }, - icon_color: self.current_container().on.into(), - }, - TextInput::Search => text_input::Appearance { - background: Color::from(bg).into(), - border: Border { - radius: palette.corner_radii.radius_m.into(), - ..Default::default() - }, - 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 { - 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()), - }, + 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 + } } } } #[derive(Default)] -pub enum TextEditor { +pub enum TextEditor<'a> { #[default] Default, - Custom(Box>), + Custom(text_editor::StyleFn<'a, Theme>), } -impl iced_style::text_editor::StyleSheet for Theme { - type Style = TextEditor; +impl iced_widget::text_editor::Catalog for Theme { + type Class<'a> = TextEditor<'a>; - fn active(&self, style: &Self::Style) -> iced_style::text_editor::Appearance { - if let TextEditor::Custom(style) = style { - return style.active(self); + 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(); - iced_style::text_editor::Appearance { - 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()), + + 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, } } - - fn focused(&self, style: &Self::Style) -> iced_style::text_editor::Appearance { - if let TextEditor::Custom(style) = style { - return style.focused(self); - } - - let cosmic = self.cosmic(); - iced_style::text_editor::Appearance { - 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), - }, - } - } - - fn placeholder_color(&self, style: &Self::Style) -> Color { - if let TextEditor::Custom(style) = style { - return style.placeholder_color(self); - } - 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 { - if let TextEditor::Custom(style) = style { - return style.value_color(self); - } - let palette = self.cosmic(); - - palette.palette.neutral_9.into() - } - - fn disabled_color(&self, style: &Self::Style) -> Color { - if let TextEditor::Custom(style) = style { - return style.disabled_color(self); - } - let palette = self.cosmic(); - let mut neutral_9 = palette.palette.neutral_9; - neutral_9.alpha = 0.5; - neutral_9.into() - } - - fn selection_color(&self, style: &Self::Style) -> Color { - if let TextEditor::Custom(style) = style { - return style.selection_color(self); - } - let cosmic = self.cosmic(); - cosmic.accent.base.into() - } - - fn disabled(&self, style: &Self::Style) -> iced_style::text_editor::Appearance { - if let TextEditor::Custom(style) = style { - return style.disabled(self); - } - self.active(style) - } } diff --git a/src/theme/style/menu_bar.rs b/src/theme/style/menu_bar.rs index 18b983fd..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; @@ -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 0469673a..bc648a73 100644 --- a/src/theme/style/mod.rs +++ b/src/theme/style/mod.rs @@ -10,8 +10,6 @@ mod dropdown; pub mod iced; #[doc(inline)] -pub use self::iced::Application; -#[doc(inline)] pub use self::iced::Checkbox; #[doc(inline)] pub use self::iced::Container; @@ -33,3 +31,8 @@ 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 3fc4ae1e..b9863c88 100644 --- a/src/theme/style/segmented_button.rs +++ b/src/theme/style/segmented_button.rs @@ -5,8 +5,9 @@ use crate::widget::segmented_button::{Appearance, ItemAppearance, StyleSheet}; use crate::{theme::Theme, widget::segmented_button::ItemStatusAppearance}; -use cosmic_theme::{Component, Container}; -use iced_core::{border::Radius, Background}; +use iced::Border; +use iced_core::{Background, border::Radius}; +use palette::WithAlpha; #[derive(Default)] pub enum SegmentedButton { @@ -15,6 +16,10 @@ pub enum SegmentedButton { TabBar, /// A widget for multiple choice selection. Control, + /// Navigation bar style + NavBar, + /// File browser + FileNav, /// Or implement any custom theme of your liking. Custom(Box Appearance>), } @@ -24,69 +29,55 @@ impl StyleSheet for Theme { #[allow(clippy::too_many_lines)] fn horizontal(&self, style: &Self::Style) -> Appearance { - let container = &self.current_container(); - + let cosmic = self.cosmic(); + let container = self.current_container(); match style { - SegmentedButton::TabBar => { - let cosmic = self.cosmic(); - let active = horizontal::tab_bar_active(cosmic); - Appearance { - border_radius: cosmic.corner_radii.radius_0.into(), - inactive: ItemStatusAppearance { - background: None, - first: ItemAppearance { - border_radius: cosmic.corner_radii.radius_0.into(), - border_bottom: Some((1.0, cosmic.accent.base.into())), - ..Default::default() - }, - middle: ItemAppearance { - border_radius: cosmic.corner_radii.radius_0.into(), - border_bottom: Some((1.0, cosmic.accent.base.into())), - ..Default::default() - }, - last: ItemAppearance { - border_radius: cosmic.corner_radii.radius_0.into(), - border_bottom: Some((1.0, cosmic.accent.base.into())), - ..Default::default() - }, - text_color: container.component.on.into(), - }, - hover: hover(cosmic, &container.component, &active), - focus: focus(cosmic, container, &active), - active, - ..Default::default() - } - } SegmentedButton::Control => { - let cosmic = self.cosmic(); - let active = horizontal::selection_active(cosmic, &container.component); - let rad_m = cosmic.corner_radii.radius_m; + 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 { - background: Some(Background::Color(container.small_widget.into())), - border_radius: rad_m.into(), + background: Some(Background::Color(container.component.base.into())), + border: Border { + radius: rad_xl.into(), + ..Default::default() + }, inactive: ItemStatusAppearance { background: None, first: ItemAppearance { - border_radius: Radius::from([rad_m[0], rad_0[1], rad_0[2], rad_m[3]]), - ..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: cosmic.corner_radii.radius_0.into(), - ..Default::default() + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + ..Default::default() + }, }, last: ItemAppearance { - border_radius: Radius::from([rad_0[0], rad_m[1], rad_m[2], rad_0[3]]), - ..Default::default() + border: Border { + radius: Radius::from([rad_0[0], rad_xl[1], rad_xl[2], rad_0[3]]), + ..Default::default() + }, }, text_color: container.component.on.into(), }, - hover: hover(cosmic, &container.component, &active), - focus: focus(cosmic, container, &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), } } @@ -94,196 +85,268 @@ impl StyleSheet for Theme { #[allow(clippy::too_many_lines)] fn vertical(&self, style: &Self::Style) -> Appearance { let cosmic = self.cosmic(); - let rad_m = cosmic.corner_radii.radius_m; - let rad_0 = cosmic.corner_radii.radius_0; + let container = self.current_container(); match style { - SegmentedButton::TabBar => { - let container = &self.cosmic().primary; - let active = vertical::tab_bar_active(cosmic); - Appearance { - border_radius: cosmic.corner_radii.radius_0.into(), - inactive: ItemStatusAppearance { - background: None, - text_color: container.component.on.into(), - ..active - }, - hover: hover(cosmic, &container.component, &active), - focus: focus(cosmic, container, &active), - active, - ..Default::default() - } - } SegmentedButton::Control => { - let container = self.current_container(); + 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 { - background: Some(Background::Color(container.small_widget.into())), - border_radius: rad_m.into(), + background: Some(Background::Color(container.component.base.into())), + border: Border { + radius: rad_xl.into(), + ..Default::default() + }, inactive: ItemStatusAppearance { background: None, first: ItemAppearance { - border_radius: Radius::from([rad_m[0], rad_m[1], rad_0[0], rad_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: cosmic.corner_radii.radius_0.into(), - ..Default::default() + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + ..Default::default() + }, }, last: ItemAppearance { - border_radius: Radius::from([rad_0[0], rad_0[1], rad_m[2], rad_m[3]]), - ..Default::default() + border: Border { + radius: Radius::from([rad_0[0], rad_0[1], rad_xl[2], rad_xl[3]]), + ..Default::default() + }, }, text_color: container.component.on.into(), }, - hover: hover(cosmic, &container.component, &active), - focus: focus(cosmic, container, &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 cosmic_theme::Component; - use iced_core::{border::Radius, Background}; + use cosmic_theme::{Component, Container}; + use iced::Border; + use iced_core::{Background, border::Radius}; + use palette::WithAlpha; + + 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() + }, + inactive: ItemStatusAppearance { + background: None, + first: ItemAppearance { border }, + middle: ItemAppearance { border }, + last: ItemAppearance { border }, + text_color: container.component.on.into(), + }, + hover: super::hover(cosmic, &active, 0.3), + pressed: super::hover(cosmic, &active, 0.25), + active, + ..Default::default() + } + } pub fn selection_active( cosmic: &cosmic_theme::Theme, component: &Component, ) -> ItemStatusAppearance { - let mut color = cosmic.palette.neutral_5; - color.alpha = 0.2; - - let rad_m = cosmic.corner_radii.radius_m; + let rad_xl = cosmic.corner_radii.radius_xl; let rad_0 = cosmic.corner_radii.radius_0; ItemStatusAppearance { - background: Some(Background::Color(color.into())), + background: Some(Background::Color( + cosmic.palette.neutral_5.with_alpha(0.1).into(), + )), first: ItemAppearance { - border_radius: Radius::from([rad_m[0], rad_0[1], rad_0[2], rad_m[3]]), - ..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: cosmic.corner_radii.radius_0.into(), - ..Default::default() + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + ..Default::default() + }, }, last: ItemAppearance { - border_radius: Radius::from([rad_0[0], rad_m[1], rad_m[2], rad_0[3]]), - ..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 tab_bar_active(cosmic: &cosmic_theme::Theme) -> ItemStatusAppearance { - let mut neutral_5 = cosmic.palette.neutral_5; - neutral_5.alpha = 0.2; let rad_s = cosmic.corner_radii.radius_s; 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.2).into(), + )), first: ItemAppearance { - border_radius: Radius::from([rad_s[0], rad_s[1], rad_0[2], rad_0[3]]), - border_bottom: Some((4.0, cosmic.accent.base.into())), - ..Default::default() + 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_radius: Radius::from([rad_s[0], rad_s[1], rad_0[2], rad_0[3]]), - border_bottom: Some((4.0, cosmic.accent.base.into())), - ..Default::default() + 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_radius: Radius::from([rad_s[0], rad_s[1], rad_0[2], rad_0[3]]), - border_bottom: Some((4.0, cosmic.accent.base.into())), - ..Default::default() + 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.base.into(), + text_color: cosmic.accent_text_color().into(), } } } -pub fn focus( - cosmic: &cosmic_theme::Theme, - container: &Container, - default: &ItemStatusAppearance, -) -> ItemStatusAppearance { - let color = container.small_widget; - ItemStatusAppearance { - background: Some(Background::Color(color.into())), - text_color: cosmic.accent.base.into(), - ..*default +mod vertical { + use super::Appearance; + use crate::widget::segmented_button::{ItemAppearance, ItemStatusAppearance}; + use cosmic_theme::{Component, Container}; + use iced::Border; + use iced_core::{Background, border::Radius}; + use palette::WithAlpha; + + 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() + }, + inactive: ItemStatusAppearance { + background: None, + text_color: container.component.on.into(), + ..active + }, + hover: super::hover(cosmic, &active, 0.3), + pressed: super::hover(cosmic, &active, 0.25), + active, + ..Default::default() + } + } + + pub fn selection_active( + cosmic: &cosmic_theme::Theme, + component: &Component, + ) -> ItemStatusAppearance { + let rad_0 = cosmic.corner_radii.radius_0; + let rad_xl = cosmic.corner_radii.radius_xl; + + ItemStatusAppearance { + background: Some(Background::Color( + cosmic.palette.neutral_5.with_alpha(0.1).into(), + )), + first: ItemAppearance { + border: Border { + radius: Radius::from([rad_xl[0], rad_xl[1], rad_0[2], rad_0[3]]), + ..Default::default() + }, + }, + middle: ItemAppearance { + border: Border { + radius: cosmic.corner_radii.radius_0.into(), + ..Default::default() + }, + }, + last: ItemAppearance { + border: Border { + radius: Radius::from([rad_0[0], rad_0[1], rad_xl[2], rad_xl[3]]), + ..Default::default() + }, + }, + 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, - component: &Component, default: &ItemStatusAppearance, + alpha: f32, ) -> ItemStatusAppearance { - let mut color = cosmic.palette.neutral_8; - color.alpha = 0.2; ItemStatusAppearance { - background: Some(Background::Color(color.into())), - text_color: cosmic.accent.base.into(), + background: Some(Background::Color( + cosmic.palette.neutral_5.with_alpha(alpha).into(), + )), + text_color: cosmic.accent_text_color().into(), ..*default } } - -mod vertical { - use crate::widget::segmented_button::{ItemAppearance, ItemStatusAppearance}; - use cosmic_theme::Component; - use iced_core::{border::Radius, Background}; - - pub fn selection_active( - cosmic: &cosmic_theme::Theme, - component: &Component, - ) -> ItemStatusAppearance { - let mut color = component.selected_state_color(); - color.alpha = 0.3; - - let rad_0 = cosmic.corner_radii.radius_0; - let rad_m = cosmic.corner_radii.radius_m; - - ItemStatusAppearance { - background: Some(Background::Color(color.into())), - first: ItemAppearance { - border_radius: Radius::from([rad_m[0], rad_m[1], rad_0[2], rad_0[3]]), - ..Default::default() - }, - middle: ItemAppearance { - border_radius: cosmic.corner_radii.radius_0.into(), - ..Default::default() - }, - last: ItemAppearance { - border_radius: Radius::from([rad_0[0], rad_0[1], rad_m[2], rad_m[3]]), - ..Default::default() - }, - text_color: cosmic.accent.base.into(), - } - } - - pub fn tab_bar_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: cosmic.corner_radii.radius_m.into(), - ..Default::default() - }, - middle: ItemAppearance { - border_radius: cosmic.corner_radii.radius_m.into(), - ..Default::default() - }, - last: ItemAppearance { - border_radius: cosmic.corner_radii.radius_m.into(), - ..Default::default() - }, - text_color: cosmic.accent.base.into(), - } - } -} diff --git a/src/theme/style/text_input.rs b/src/theme/style/text_input.rs index c809961a..8085a48d 100644 --- a/src/theme/style/text_input.rs +++ b/src/theme/style/text_input.rs @@ -6,6 +6,7 @@ use crate::ext::ColorExt; use crate::widget::text_input::{Appearance, StyleSheet}; use iced_core::Color; +use palette::WithAlpha; #[derive(Default)] pub enum TextInput { @@ -31,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; @@ -125,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; @@ -188,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; @@ -283,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; 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 c918f8c7..577bea95 100644 --- a/src/widget/aspect_ratio.rs +++ b/src/widget/aspect_ratio.rs @@ -1,17 +1,18 @@ //! A container which constraints itself to a specific aspect ratio. -use iced::widget::Container; 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, @@ -33,7 +34,7 @@ where container: Container<'a, Message, crate::Theme, Renderer>, } -impl<'a, Message, Renderer> AspectRatio<'a, Message, Renderer> +impl AspectRatio<'_, Message, Renderer> where Renderer: iced_core::Renderer, { @@ -75,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 @@ -82,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 @@ -89,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 @@ -96,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 @@ -103,42 +108,54 @@ 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, { @@ -155,7 +172,7 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, @@ -169,29 +186,27 @@ where } 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, @@ -239,10 +254,24 @@ where fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - self.container.overlay(tree, layout, renderer) + 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) } } 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 311f8ebc..04d2bdd5 100644 --- a/src/widget/button/icon.rs +++ b/src/widget/button/icon.rs @@ -1,14 +1,11 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use super::{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>; @@ -29,7 +26,7 @@ pub fn icon<'a, Message>(handle: impl Into) -> Button<'a, Message> { }) } -impl<'a, Message> Button<'a, Message> { +impl Button<'_, Message> { pub fn new(icon: Icon) -> Self { let guard = crate::theme::THEME.lock().unwrap(); let theme = guard.cosmic(); @@ -38,6 +35,10 @@ impl<'a, Message> Button<'a, Message> { 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, @@ -48,7 +49,7 @@ impl<'a, Message> Button<'a, Message> { line_height: 20, font_size: 14, font_weight: Weight::Normal, - style: Style::Icon, + class: ButtonClass::Icon, variant: icon, } } @@ -114,26 +115,24 @@ impl<'a, Message> Button<'a, Message> { 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) @@ -153,11 +152,11 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes ); } - 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) + .align_x(Alignment::Center) .apply(super::custom) } else { crate::widget::row::with_children(content) @@ -165,27 +164,36 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes .width(builder.width) .height(builder.height) .spacing(builder.spacing) - .align_items(Alignment::Center) + .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) .selected(builder.variant.selected) - .style(builder.style); + .class(builder.class); if builder.tooltip.is_empty() { button.into() } else { - tooltip(button, builder.tooltip, tooltip::Position::Top) - .size(builder.font_size) - .font(crate::font::Font { - weight: builder.font_weight, - ..crate::font::default() - }) - .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 d93844cd..ab51e667 100644 --- a/src/widget/button/image.rs +++ b/src/widget/button/image.rs @@ -1,12 +1,12 @@ // 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>>; @@ -28,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, @@ -42,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 @@ -75,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 e62b05bf..9ce81268 100644 --- a/src/widget/button/link.rs +++ b/src/widget/button/link.rs @@ -4,13 +4,13 @@ //! 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>; @@ -34,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, @@ -44,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, } } @@ -55,13 +59,14 @@ 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> = row::with_capacity(2) + let mut button: super::Button<'a, Message> = row::with_capacity(2) .push({ // TODO: Avoid allocation crate::widget::text(builder.label.to_string()) @@ -81,23 +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) + .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(crate::font::Font { - weight: builder.font_weight, - ..crate::font::default() - }) - .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 4a83b12e..f5975d39 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -3,30 +3,30 @@ //! Button widgets for COSMIC applications. -pub use crate::theme::Button as Style; +pub use crate::theme::Button as ButtonClass; pub mod link; use derive_setters::Setters; #[doc(inline)] -pub use link::link; -#[doc(inline)] pub use link::Button as LinkButton; +#[doc(inline)] +pub use link::link; mod icon; #[doc(inline)] -pub use icon::icon; -#[doc(inline)] pub use icon::Button as IconButton; +#[doc(inline)] +pub use icon::icon; mod image; #[doc(inline)] -pub use image::image; -#[doc(inline)] pub use image::Button as ImageButton; +#[doc(inline)] +pub use image::image; mod style; #[doc(inline)] -pub use style::{Appearance, StyleSheet}; +pub use style::{Catalog, Style}; mod text; #[doc(inline)] @@ -36,7 +36,7 @@ pub use text::{destructive, standard, suggested, text}; mod widget; #[doc(inline)] -pub use widget::{draw, focus, layout, mouse_interaction, Button}; +pub use widget::{Button, draw, focus, layout, mouse_interaction}; use iced_core::font::Weight; use iced_core::widget::Id; @@ -44,16 +44,18 @@ use iced_core::{Length, Padding}; use std::borrow::Cow; /// A button with a custom element for its content. -pub fn custom<'a, Message>(content: impl Into>) -> Button<'a, Message> { - Button::new(content) +pub fn custom<'a, Message: Clone + 'a>( + content: impl Into>, +) -> Button<'a, Message> { + Button::new(content.into()) } /// An image button which may contain any widget as its content. -pub fn custom_image_button<'a, Message>( +pub fn custom_image_button<'a, Message: Clone + 'a>( content: impl Into>, on_remove: Option, ) -> Button<'a, Message> { - Button::new_image(content, on_remove) + Button::new_image(content.into(), on_remove) } /// A builder for constructing a custom [`Button`]. @@ -67,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>, @@ -105,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 87f27770..21afa08b 100644 --- a/src/widget/button/style.rs +++ b/src/widget/button/style.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: MPL-2.0 //! Change the apperance of a button. -use iced_core::{border::Radius, Background, 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, @@ -41,7 +41,7 @@ pub struct Appearance { pub text_color: Option, } -impl Appearance { +impl Style { // TODO: `Radius` is not `const fn` compatible. pub fn new() -> Self { let rad_0 = THEME.lock().unwrap().cosmic().corner_radii.radius_0; @@ -60,33 +60,34 @@ impl Appearance { } } -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, selected: 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 { + 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, selected: 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, selected: 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 0a59976a..bcdd02ba 100644 --- a/src/widget/button/text.rs +++ b/src/widget/button/text.rs @@ -1,11 +1,10 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use super::{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>; @@ -14,14 +13,14 @@ pub type Button<'a, Message> = Builder<'a, Message, Text>; 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 @@ -31,7 +30,9 @@ pub fn standard<'a, Message>(label: impl Into>) -> Button<'a, Messa /// 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. @@ -40,6 +41,12 @@ pub struct Text { pub(super) trailing_icon: Option, } +impl Default for Text { + fn default() -> Self { + Self::new() + } +} + impl Text { pub const fn new() -> Self { Self { @@ -49,13 +56,17 @@ impl Text { } } -impl<'a, Message> Button<'a, Message> { +impl Button<'_, Message> { pub fn new(text: Text) -> Self { 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, @@ -66,7 +77,7 @@ impl<'a, Message> Button<'a, Message> { line_height: 20, font_size: 14, font_weight: Weight::Normal, - style: Style::Standard, + class: ButtonClass::Standard, variant: text, } } @@ -84,21 +95,15 @@ 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 font = crate::font::Font { @@ -114,7 +119,7 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes .into() }); - let button: super::Button<'a, Message> = 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. @@ -125,23 +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) + .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(crate::font::Font { - weight: builder.font_weight, - ..crate::font::default() - }) - .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 9dd969f0..4acf3f2d 100644 --- a/src/widget/button/widget.rs +++ b/src/widget/button/widget.rs @@ -7,24 +7,24 @@ //! 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::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::{mouse, Border}; -use iced_core::{overlay, Shadow}; +use iced_core::widget::tree::{self, Tree}; use iced_core::{ 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; use crate::theme::THEME; -pub use super::style::{Appearance, StyleSheet}; +pub use super::style::{Catalog, Style}; /// Internally defines different button widget variants. enum Variant { @@ -47,17 +47,18 @@ pub struct Button<'a, Message> { #[cfg(feature = "a11y")] label: Option>, content: crate::Element<'a, Message>, - on_press: Option, - on_press_down: Option, + on_press: Option Message + 'a>>, + on_press_down: Option Message + 'a>>, width: Length, height: Length, padding: Padding, selected: bool, style: crate::theme::Button, variant: Variant, + force_enabled: bool, } -impl<'a, Message> Button<'a, Message> { +impl<'a, Message: Clone + 'a> Button<'a, Message> { /// Creates a new [`Button`] with the given content. pub(super) fn new(content: impl Into>) -> Self { Self { @@ -77,6 +78,7 @@ impl<'a, Message> Button<'a, Message> { selected: false, style: crate::theme::Button::default(), variant: Variant::Normal, + force_enabled: false, } } @@ -90,6 +92,7 @@ impl<'a, Message> Button<'a, Message> { name: None, #[cfg(feature = "a11y")] description: None, + force_enabled: false, #[cfg(feature = "a11y")] label: None, content: content.into(), @@ -115,24 +118,28 @@ impl<'a, Message> Button<'a, Message> { } /// 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 @@ -141,16 +148,42 @@ impl<'a, Message> Button<'a, Message> { /// 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(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(on_press); + 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 } @@ -158,14 +191,65 @@ impl<'a, Message> Button<'a, Message> { /// 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; @@ -173,7 +257,8 @@ impl<'a, Message> Button<'a, Message> { } /// Sets the style variant of this [`Button`]. - pub fn style(mut self, style: crate::theme::Button) -> Self { + #[inline] + pub fn class(mut self, style: crate::theme::Button) -> Self { self.style = style; self } @@ -233,7 +318,7 @@ impl<'a, Message: 'a + Clone> Widget } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &crate::Renderer, limits: &layout::Limits, @@ -246,42 +331,47 @@ impl<'a, Message: 'a + Clone> Widget self.padding, |renderer, limits| { self.content - .as_widget() + .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) }, ) } fn operate( - &self, + &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &crate::Renderer, - operation: &mut dyn Operation>, + 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: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { + ) { if let Variant::Image { on_remove: Some(on_remove), .. @@ -294,7 +384,8 @@ impl<'a, Message: 'a + Clone> Widget 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; } } } @@ -302,19 +393,22 @@ impl<'a, Message: 'a + Clone> Widget _ => (), } } - - 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( @@ -323,8 +417,8 @@ impl<'a, Message: 'a + Clone> Widget layout, cursor, shell, - &self.on_press, - &self.on_press_down, + self.on_press.as_deref(), + self.on_press_down.as_deref(), || tree.state.downcast_mut::(), ) } @@ -338,14 +432,22 @@ impl<'a, Message: 'a + Clone> Widget 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; + } + + // 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(); + 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::(); @@ -363,7 +465,6 @@ impl<'a, Message: 'a + Clone> Widget 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 { @@ -391,6 +492,7 @@ impl<'a, Message: 'a + Clone> Widget draw::<_, crate::Theme>( renderer, bounds, + *viewport, &styling, |renderer, _styling| { self.content.as_widget().draw( @@ -402,9 +504,9 @@ impl<'a, Message: 'a + Clone> Widget 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 { .. }), @@ -415,37 +517,11 @@ impl<'a, Message: 'a + Clone> Widget on_remove, } = &self.variant { - let mut parent_bounds = bounds; - parent_bounds.y -= 8.0; - parent_bounds.width += 16.0; - parent_bounds.height += 16.0; - - renderer.with_layer(parent_bounds, |renderer| { + renderer.with_layer(*viewport, |renderer| { let selection_background = theme.selection_background(); let c_rad = THEME.lock().unwrap().cosmic().corner_radii; - // NOTE: Workaround to round the border of the unselected, unhovered image. - if !self.selected && !is_mouse_over { - let mut bounds = bounds; - bounds.x -= 2.0; - bounds.y -= 2.0; - bounds.width += 4.0; - bounds.height += 4.0; - renderer.fill_quad( - renderer::Quad { - bounds, - border: Border { - width: 2.0, - color: crate::theme::active().current_container().base.into(), - radius: 9.0.into(), - }, - shadow: Shadow::default(), - }, - Color::TRANSPARENT, - ); - } - if self.selected { renderer.fill_quad( Quad { @@ -466,21 +542,22 @@ impl<'a, Message: 'a + Clone> Widget ..Default::default() }, shadow: Shadow::default(), + snap: true, }, selection_background, ); - iced_core::svg::Renderer::draw( - renderer, - crate::widget::common::object_select().clone(), - Some(icon_color), - 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), - }, - ); + 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 bounds.intersects(viewport) { + iced_core::svg::Renderer::draw_svg(renderer, svg_handle, bounds, bounds); + } } if on_remove.is_some() { @@ -495,14 +572,20 @@ impl<'a, Message: 'a + Clone> Widget radius: c_rad.radius_m.into(), ..Default::default() }, + snap: true, }, selection_background, ); - - iced_core::svg::Renderer::draw( + let svg_handle = svg::Svg::new(close_icon.clone()).color(icon_color); + iced_core::svg::Renderer::draw_svg( renderer, - close_icon.clone(), - Some(icon_color), + 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, @@ -525,19 +608,34 @@ impl<'a, Message: 'a + Clone> Widget _viewport: &Rectangle, _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<'_>, + 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, ) } @@ -550,16 +648,17 @@ impl<'a, Message: 'a + Clone> Widget 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, @@ -570,21 +669,16 @@ impl<'a, Message: 'a + Clone> Widget 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()); @@ -599,12 +693,22 @@ impl<'a, Message: 'a + Clone> Widget if self.on_press.is_none() { 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 { @@ -633,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; } @@ -660,39 +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_down: &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 { .. }) => { + // 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; if let Some(on_press_down) = on_press_down { - shell.publish(on_press_down.clone()); + let msg = (on_press_down)(layout.virtual_offset(), layout.bounds()); + shell.publish(msg); } - return event::Status::Captured; + 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 { @@ -701,10 +814,12 @@ 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(); @@ -714,22 +829,28 @@ pub fn update<'a, Message: Clone>( #[cfg(feature = "a11y")] Event::A11y(event_id, iced_accessibility::accesskit::ActionRequest { action, .. }) => { let state = state(); - if let Some(Some(on_press)) = (event_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, .. }) => { - if let Some(on_press) = on_press.clone() { + if let Some(on_press) = on_press { let state = state(); - if state.is_focused && key == keyboard::Key::Named(keyboard::key::Named::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; } } } @@ -740,19 +861,18 @@ pub fn update<'a, Message: Clone>( } _ => {} } - - event::Status::Ignored } #[allow(clippy::too_many_arguments)] pub fn draw( renderer: &mut Renderer, bounds: Rectangle, - styling: &super::style::Appearance, - draw_contents: impl FnOnce(&mut Renderer, &Appearance), + viewport_bounds: Rectangle, + styling: &super::style::Style, + draw_contents: impl FnOnce(&mut Renderer, &Style), is_image: bool, ) where - Theme: super::style::StyleSheet, + Theme: super::style::Catalog, { let doubled_border_width = styling.border_width * 2.0; let doubled_outline_width = styling.outline_width * 2.0; @@ -772,6 +892,7 @@ pub fn draw( radius: styling.border_radius, }, shadow: Shadow::default(), + snap: true, }, Color::TRANSPARENT, ); @@ -793,6 +914,7 @@ pub fn draw( ..Default::default() }, shadow: Shadow::default(), + snap: true, }, Background::Color([0.0, 0.0, 0.0, 0.5].into()), ); @@ -808,6 +930,7 @@ pub fn draw( ..Default::default() }, shadow: Shadow::default(), + snap: true, }, background, ); @@ -823,6 +946,7 @@ pub fn draw( ..Default::default() }, shadow: Shadow::default(), + snap: true, }, overlay, ); @@ -831,27 +955,12 @@ pub fn draw( // Then draw the button contents onto the background. draw_contents(renderer, styling); - let mut clipped_bounds = bounds; + let mut clipped_bounds = viewport_bounds.intersection(&bounds).unwrap_or_default(); clipped_bounds.height += styling.border_width; + clipped_bounds.width += 1.0; + // Finish by drawing the border above the contents. renderer.with_layer(clipped_bounds, |renderer| { - // NOTE: Workaround to round the border of the hovered/selected image. - if is_image { - renderer.fill_quad( - renderer::Quad { - bounds, - border: Border { - width: styling.border_width, - color: crate::theme::active().current_container().base.into(), - radius: 0.0.into(), - }, - shadow: Shadow::default(), - }, - Color::TRANSPARENT, - ); - } - - // Finish by drawing the border above the contents. renderer.fill_quad( renderer::Quad { bounds, @@ -861,10 +970,11 @@ pub fn draw( radius: styling.border_radius, }, shadow: Shadow::default(), + snap: true, }, Color::TRANSPARENT, ); - }); + }) } else { draw_contents(renderer, styling); } @@ -909,20 +1019,23 @@ pub fn mouse_interaction( } } -/// Produces a [`Command`] that focuses the [`Button`] with the given [`Id`]. -pub fn focus(id: Id) -> Command { - Command::widget(operation::focusable::focus(id)) +/// Produces a [`Task`] that focuses the [`Button`] with the given [`Id`]. +pub fn focus(id: Id) -> Task { + task::effect(Action::Widget(Box::new(operation::focusable::focus(id)))) } impl operation::Focusable for State { + #[inline] fn is_focused(&self) -> bool { Self::is_focused(*self) } + #[inline] fn focus(&mut self) { Self::focus(self); } + #[inline] fn unfocus(&mut self) { Self::unfocus(self); } diff --git a/src/widget/calendar.rs b/src/widget/calendar.rs index f86841a9..91c601d3 100644 --- a/src/widget/calendar.rs +++ b/src/widget/calendar.rs @@ -3,57 +3,94 @@ //! A widget that displays an interactive calendar. -use std::cmp; - -use crate::iced_core::{Length, Padding}; -use crate::widget::{button, column, grid, icon, row, text, Grid}; -use chrono::{Datelike, Days, Months, NaiveDate, Weekday}; -use iced::alignment::{Horizontal, Vertical}; +use crate::fl; +use crate::widget::{button, column, grid, icon, row, text}; +use apply::Apply; +use iced::alignment::Vertical; +use iced_core::{Alignment, Length}; +use jiff::{ + ToSpan, + civil::{Date, Weekday}, +}; /// A widget that displays an interactive calendar. pub fn calendar( - selected: &NaiveDate, - on_select: impl Fn(NaiveDate) -> M + 'static, -) -> Calendar { + model: &CalendarModel, + on_select: impl Fn(Date) -> M + 'static, + on_prev: impl Fn() -> M + 'static, + on_next: impl Fn() -> M + 'static, + first_day_of_week: Weekday, +) -> Calendar<'_, M> { Calendar { - selected, + model, on_select: Box::new(on_select), + on_prev: Box::new(on_prev), + on_next: Box::new(on_next), + first_day_of_week, } } -pub fn set_day(date_selected: NaiveDate, day: u32) -> NaiveDate { - let current = date_selected.day(); +pub fn set_day(date_selected: Date, day: i8) -> Date { + date_selected + .with() + .day(day) + .build() + .unwrap_or(date_selected) +} - let new_date = match current.cmp(&day) { - cmp::Ordering::Less => date_selected.checked_add_days(Days::new((day - current) as u64)), +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] +pub struct CalendarModel { + pub selected: Date, + pub visible: Date, +} - cmp::Ordering::Greater => date_selected.checked_sub_days(Days::new((current - day) as u64)), - - _ => None, - }; - - if let Some(new) = new_date { - new - } else { - date_selected +impl CalendarModel { + pub fn now() -> Self { + let now = jiff::Zoned::now().date(); + CalendarModel { + selected: now, + visible: now, + } } -} -pub fn set_prev_month(date_selected: NaiveDate) -> NaiveDate { - date_selected - .checked_sub_months(Months::new(1)) - .expect("valid naivedate") -} + #[inline] + pub fn new(selected: Date, visible: Date) -> Self { + CalendarModel { selected, visible } + } -pub fn set_next_month(date_selected: NaiveDate) -> NaiveDate { - date_selected - .checked_add_months(Months::new(1)) - .expect("valid naivedate") + pub fn show_prev_month(&mut self) { + self.visible = self.visible.checked_sub(1.month()).expect("valid date"); + } + + pub fn show_next_month(&mut self) { + self.visible = self.visible.checked_add(1.month()).expect("valid date"); + } + + #[inline] + pub fn set_prev_month(&mut self) { + self.show_prev_month(); + self.selected = self.visible; + } + + #[inline] + pub fn set_next_month(&mut self) { + self.show_next_month(); + self.selected = self.visible; + } + + #[inline] + pub fn set_selected_visible(&mut self, selected: Date) { + self.selected = selected; + self.visible = self.selected; + } } pub struct Calendar<'a, M> { - selected: &'a NaiveDate, - on_select: Box M>, + model: &'a CalendarModel, + on_select: Box M>, + on_prev: Box M>, + on_next: Box M>, + first_day_of_week: Weekday, } impl<'a, Message> From> for crate::Element<'a, Message> @@ -61,123 +98,169 @@ where Message: Clone + 'static, { fn from(this: Calendar<'a, Message>) -> Self { - let date = text(this.selected.format("%B %-d, %Y").to_string()).size(18); - let day_of_week = text(this.selected.format("%A").to_string()).size(14); + macro_rules! translate_month { + ($month:expr, $year:expr) => {{ + match $month { + 1 => fl!("january", year = $year), + 2 => fl!("february", year = $year), + 3 => fl!("march", year = $year), + 4 => fl!("april", year = $year), + 5 => fl!("may", year = $year), + 6 => fl!("june", year = $year), + 7 => fl!("july", year = $year), + 8 => fl!("august", year = $year), + 9 => fl!("september", year = $year), + 10 => fl!("october", year = $year), + 11 => fl!("november", year = $year), + 12 => fl!("december", year = $year), + _ => unreachable!(), + } + }}; + } + macro_rules! translate_weekday { + ($weekday:expr, short) => {{ + match $weekday { + Weekday::Monday => fl!("mon"), + Weekday::Tuesday => fl!("tue"), + Weekday::Wednesday => fl!("wed"), + Weekday::Thursday => fl!("thu"), + Weekday::Friday => fl!("fri"), + Weekday::Saturday => fl!("sat"), + Weekday::Sunday => fl!("sun"), + } + }}; + ($weekday:expr, long) => {{ + match $weekday { + Weekday::Monday => fl!("monday"), + Weekday::Tuesday => fl!("tuesday"), + Weekday::Wednesday => fl!("wednesday"), + Weekday::Thursday => fl!("thursday"), + Weekday::Friday => fl!("friday"), + Weekday::Saturday => fl!("saturday"), + Weekday::Sunday => fl!("sunday"), + } + }}; + } + + let date = text(translate_month!( + this.model.visible.month(), + this.model.visible.year() + )) + .size(18); + + let day = text::body(translate_weekday!(this.model.visible.weekday(), long)); let month_controls = row::with_capacity(2) + .spacing(8) .push( - button::icon(icon::from_name("go-previous-symbolic")) - .padding([0, 12]) - .on_press((this.on_select)(set_prev_month(this.selected.clone()))), + icon::from_name("go-previous-symbolic") + .apply(button::icon) + .on_press((this.on_prev)()), ) .push( - button::icon(icon::from_name("go-next-symbolic")) - .padding([0, 12]) - .on_press((this.on_select)(set_next_month(this.selected.clone()))), + icon::from_name("go-next-symbolic") + .apply(button::icon) + .on_press((this.on_next)()), ); - // Calender - let mut calendar_grid: Grid<'_, Message> = - grid().padding([0, 12].into()).width(Length::Fill); + // Calendar + let mut calendar_grid = grid().padding([0, 12].into()).width(Length::Fill); - let mut first_day_of_week = Weekday::Sun; // TODO: Configurable + let mut first_day_of_week = this.first_day_of_week; for _ in 0..7 { calendar_grid = calendar_grid.push( - text(first_day_of_week.to_string()) - .size(12) - .width(Length::Fixed(36.0)) - .horizontal_alignment(Horizontal::Center), + text::caption(translate_weekday!(first_day_of_week, short)) + .width(Length::Fixed(44.0)) + .align_x(Alignment::Center), ); - first_day_of_week = first_day_of_week.succ(); + first_day_of_week = first_day_of_week.next(); } calendar_grid = calendar_grid.insert_row(); - let monday = get_calender_first( - this.selected.year(), - this.selected.month(), - first_day_of_week, + let first = get_calendar_first( + this.model.visible.year(), + this.model.visible.month(), + this.first_day_of_week, ); - let mut day_iter = monday.iter_days(); + + let today = jiff::Zoned::now().date(); for i in 0..42 { if i > 0 && i % 7 == 0 { calendar_grid = calendar_grid.insert_row(); } - let date = day_iter.next().unwrap(); - let is_month = - date.month() == this.selected.month() && date.year_ce() == this.selected.year_ce(); - let is_day = date.day() == this.selected.day() && is_month; + let date = first + .checked_add(i.days()) + .expect("valid date in calendar range"); + let is_currently_viewed_month = + date.first_of_month() == this.model.visible.first_of_month(); + let is_currently_selected_month = + date.first_of_month() == this.model.selected.first_of_month(); + let is_currently_selected_day = + date.day() == this.model.selected.day() && is_currently_selected_month; + let is_today = date == today; - calendar_grid = - calendar_grid.push(date_button(date, is_month, is_day, &this.on_select)); + calendar_grid = calendar_grid.push(date_button( + date, + is_currently_viewed_month, + is_currently_selected_day, + is_today, + &this.on_select, + )); } - let content_list = column::with_children(vec![ - row::with_children(vec![ - column::with_children(vec![date.into(), day_of_week.into()]).into(), - crate::widget::Space::with_width(Length::Fill).into(), + let content_list = column::with_children([ + row::with_children([ + column([date.into(), day.into()]).into(), + crate::widget::space::horizontal() + .width(Length::Fill) + .into(), month_controls.into(), ]) + .align_y(Vertical::Center) .padding([12, 20]) .into(), calendar_grid.into(), - padded_control(crate::widget::divider::horizontal::default()).into(), ]) - .width(315) + .width(360) .padding([8, 0]); Self::new(content_list) } } -fn date_button( - date: NaiveDate, - is_month: bool, - is_day: bool, - on_select: &dyn Fn(NaiveDate) -> Message, +fn date_button( + date: Date, + is_currently_viewed_month: bool, + is_currently_selected_day: bool, + is_today: bool, + on_select: &dyn Fn(Date) -> Message, ) -> crate::widget::Button<'static, Message> { - let style = if is_day { - button::Style::Suggested + let style = if is_currently_selected_day { + button::ButtonClass::Suggested + } else if is_today { + button::ButtonClass::Standard } else { - button::Style::Text + button::ButtonClass::Text }; - let button = button::custom( - text(format!("{}", date.day())) - .horizontal_alignment(Horizontal::Center) - .vertical_alignment(Vertical::Center), - ) - .style(style) - .height(Length::Fixed(36.0)) - .width(Length::Fixed(36.0)); + let button = button::custom(text(format!("{}", date.day())).center()) + .class(style) + .height(Length::Fixed(44.0)) + .width(Length::Fixed(44.0)); - if is_month { + if is_currently_viewed_month { button.on_press((on_select)(set_day(date, date.day()))) } else { button } } -/// Gets the first date that will be visible on the calender +/// Gets the first date that will be visible on the calendar #[must_use] -pub fn get_calender_first(year: i32, month: u32, from_weekday: Weekday) -> NaiveDate { - let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); - let num_days = (date.weekday() as u32 + 7 - from_weekday as u32) % 7; // chrono::Weekday.num_days_from - date.checked_sub_days(Days::new(num_days as u64)).unwrap() -} - -// TODO: Refactor to use same function from applet module. -fn padded_control<'a, Message>( - content: impl Into>, -) -> crate::widget::container::Container<'a, Message, crate::Theme, crate::Renderer> { - crate::widget::container(content) - .padding(menu_control_padding()) - .width(Length::Fill) -} - -fn menu_control_padding() -> Padding { - let guard = crate::theme::THEME.lock().unwrap(); - let cosmic = guard.cosmic(); - [cosmic.space_xxs(), cosmic.space_m()].into() +pub fn get_calendar_first(year: i16, month: i8, from_weekday: Weekday) -> Date { + let date = Date::new(year, month, 1).expect("valid date"); + let num_days = date.weekday().since(from_weekday); + date.checked_sub(num_days.days()).expect("valid date") } diff --git a/src/widget/card/style.rs b/src/widget/card/style.rs index 1a3d2935..0e63e846 100644 --- a/src/widget/card/style.rs +++ b/src/widget/card/style.rs @@ -5,12 +5,12 @@ use iced_core::{Background, Color}; /// Appearance of the cards. #[derive(Clone, Copy)] -pub struct Appearance { +pub struct Style { pub card_1: Background, pub card_2: Background, } -impl Default for Appearance { +impl Default for Style { fn default() -> Self { Self { card_1: Background::Color(Color::WHITE), @@ -20,7 +20,28 @@ impl Default for Appearance { } /// Defines the [`Appearance`] of a cards. -pub trait StyleSheet { +pub trait Catalog { /// The default [`Appearance`] of the cards. - fn default(&self) -> Appearance; + fn default(&self) -> Style; +} + +impl crate::widget::card::style::Catalog for crate::Theme { + fn default(&self) -> crate::widget::card::style::Style { + let cosmic = self.cosmic(); + + match self.layer { + cosmic_theme::Layer::Background => crate::widget::card::style::Style { + 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::Style { + 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::Style { + card_1: Background::Color(cosmic.secondary.component.hover.into()), + card_2: Background::Color(cosmic.secondary.component.pressed.into()), + }, + } + } } diff --git a/src/widget/cards.rs b/src/widget/cards.rs new file mode 100644 index 00000000..66267a73 --- /dev/null +++ b/src/widget/cards.rs @@ -0,0 +1,586 @@ +//! An expandable stack of cards +use std::time::Duration; + +use crate::{ + anim, + widget::{ + button, + card::style::Style, + column, + icon::{self, Handle}, + row, text, + }, +}; +use float_cmp::approx_eq; +use iced::widget; +use iced_core::{ + Border, Element, Event, Length, Shadow, Size, Vector, Widget, border::Radius, id::Id, + layout::Node, renderer::Quad, widget::Tree, +}; +use iced_core::{widget::tree, window}; + +const ICON_SIZE: u16 = 16; +const TOP_SPACING: u16 = 4; +const VERTICAL_SPACING: f32 = 8.0; +const PADDING: u16 = 16; +const BG_CARD_VISIBLE_HEIGHT: f32 = 4.0; +const BG_CARD_BORDER_RADIUS: f32 = 8.0; +const BG_CARD_MARGIN_STEP: f32 = 8.0; + +/// get an expandable stack of cards +#[allow(clippy::too_many_arguments)] +pub fn cards<'a, Message, F, G>( + id: widget::Id, + card_inner_elements: Vec>, + on_clear_all: Message, + on_show_more: Option, + on_activate: Option, + show_more_label: &'a str, + show_less_label: &'a str, + clear_all_label: &'a str, + show_less_icon: Option, + expanded: bool, +) -> Cards<'a, Message, crate::Renderer> +where + Message: 'static + Clone, + F: 'a + Fn(bool) -> Message, + G: 'a + Fn(usize) -> Message, +{ + Cards::new( + id, + card_inner_elements, + on_clear_all, + on_show_more, + on_activate, + show_more_label, + show_less_label, + clear_all_label, + show_less_icon, + expanded, + ) +} + +impl<'a, Message, Renderer> Cards<'a, Message, Renderer> +where + Renderer: iced_core::text::Renderer, +{ + fn fully_expanded(&self, t: f32) -> bool { + self.expanded && self.elements.len() > 1 && self.can_show_more && approx_eq!(f32, t, 1.0) + } + + fn fully_unexpanded(&self, t: f32) -> bool { + self.elements.len() == 1 + || (!self.expanded && (!self.can_show_more || approx_eq!(f32, t, 0.0))) + } +} + +/// An expandable stack of cards. +#[allow(missing_debug_implementations)] +pub struct Cards<'a, Message, Renderer = crate::Renderer> +where + Renderer: iced_core::text::Renderer, +{ + id: Id, + show_less_button: Element<'a, Message, crate::Theme, Renderer>, + clear_all_button: Element<'a, Message, crate::Theme, Renderer>, + elements: Vec>, + expanded: bool, + can_show_more: bool, + width: Length, + anim_multiplier: f32, + duration: Duration, +} + +impl<'a, Message> Cards<'a, Message, crate::Renderer> +where + Message: Clone + 'static, +{ + /// Get an expandable stack of cards + #[allow(clippy::too_many_arguments)] + pub fn new( + id: widget::Id, + card_inner_elements: Vec>, + on_clear_all: Message, + on_show_more: Option, + on_activate: Option, + show_more_label: &'a str, + show_less_label: &'a str, + clear_all_label: &'a str, + show_less_icon: Option, + expanded: bool, + ) -> Self + where + F: 'a + Fn(bool) -> Message, + G: 'a + Fn(usize) -> Message, + { + let can_show_more = card_inner_elements.len() > 1 && on_show_more.is_some(); + + Self { + can_show_more, + id: Id::unique(), + show_less_button: { + let mut show_less_children = Vec::with_capacity(3); + if let Some(source) = show_less_icon { + show_less_children.push(icon::icon(source).size(ICON_SIZE).into()); + } + show_less_children.push(text::body(show_less_label).width(Length::Shrink).into()); + show_less_children.push( + icon::from_name("pan-up-symbolic") + .size(ICON_SIZE) + .icon() + .into(), + ); + + let button_content = row::with_children(show_less_children) + .align_y(iced_core::Alignment::Center) + .spacing(TOP_SPACING) + .width(Length::Shrink); + + Element::from( + button::custom(button_content) + .class(crate::theme::Button::Text) + .width(Length::Shrink) + .on_press_maybe(on_show_more.as_ref().map(|f| f(false))) + .padding([PADDING / 2, PADDING]), + ) + }, + clear_all_button: Element::from( + button::custom(text(clear_all_label)) + .class(crate::theme::Button::Text) + .width(Length::Shrink) + .on_press(on_clear_all) + .padding([PADDING / 2, PADDING]), + ), + elements: card_inner_elements + .into_iter() + .enumerate() + .map(|(i, w)| { + let custom_content = if i == 0 && !expanded && can_show_more { + column::with_capacity(2) + .push(w) + .push(text::caption(show_more_label)) + .spacing(VERTICAL_SPACING) + .align_x(iced_core::Alignment::Center) + .into() + } else { + w + }; + + let b = crate::iced::widget::button(custom_content) + .class(crate::theme::iced::Button::Card) + .padding(PADDING); + if i == 0 && !expanded && can_show_more { + b.on_press_maybe(on_show_more.as_ref().map(|f| f(true))) + } else { + b.on_press_maybe(on_activate.as_ref().map(|f| f(i))) + } + .into() + }) + // we will set the width of the container to shrink, then when laying out the top bar + // we will set the fill limit to the max of the shrink top bar width and the max shrink width of the + // cards + .collect(), + width: Length::Shrink, + anim_multiplier: 1.0, + expanded, + duration: Duration::from_millis(200), + } + } + + /// Set the width of the cards stack + #[must_use] + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + #[must_use] + /// The default animation time is 100ms, to speed up the toggle + /// animation use a value less than 1.0, and to slow down the + /// animation use a value greater than 1.0. + pub fn anim_multiplier(mut self, multiplier: f32) -> Self { + self.anim_multiplier = multiplier; + self + } + + pub fn duration(mut self, dur: Duration) -> Self { + self.duration = dur; + self + } + + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } +} + +impl<'a, Message, Renderer> Widget for Cards<'a, Message, Renderer> +where + Message: 'a + Clone, + Renderer: 'a + iced_core::Renderer + iced_core::text::Renderer, +{ + fn children(&self) -> Vec { + [&self.show_less_button, &self.clear_all_button] + .iter() + .map(|w| Tree::new(w.as_widget())) + .chain(self.elements.iter().map(|w| Tree::new(w.as_widget()))) + .collect() + } + + fn diff(&mut self, tree: &mut Tree) { + let mut children: Vec<_> = vec![ + self.show_less_button.as_widget_mut(), + self.clear_all_button.as_widget_mut(), + ] + .into_iter() + .chain( + self.elements + .iter_mut() + .map(iced_core::Element::as_widget_mut), + ) + .collect(); + + tree.diff_children(children.as_mut_slice()); + } + + #[allow(clippy::too_many_lines)] + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + let my_state = tree.state.downcast_ref::(); + + let mut children = Vec::with_capacity(1 + self.elements.len()); + let mut size = Size::new(0.0, 0.0); + let tree_children = &mut tree.children; + let count = self.elements.len(); + if self.elements.is_empty() { + return Node::with_children(Size::new(1., 1.), children); + } + let s = anim::smootherstep(my_state.anim.t(self.duration, self.expanded)); + let fully_expanded: bool = self.fully_expanded(s); + let fully_unexpanded: bool = self.fully_unexpanded(s); + + let show_less = &mut self.show_less_button; + let clear_all = &mut self.clear_all_button; + + let show_less_node = if self.can_show_more { + show_less + .as_widget_mut() + .layout(&mut tree_children[0], renderer, limits) + } else { + Node::new(Size::default()) + }; + let clear_all_node = + clear_all + .as_widget_mut() + .layout(&mut tree_children[1], renderer, limits); + size.width += show_less_node.size().width + clear_all_node.size().width; + + let custom_limits = limits.min_width(size.width); + for (c, t) in self.elements.iter_mut().zip(tree_children[2..].iter_mut()) { + let card_node = c.as_widget_mut().layout(t, renderer, &custom_limits); + size.width = size.width.max(card_node.size().width); + } + + if fully_expanded { + let show_less = &mut self.show_less_button; + let clear_all = &mut self.clear_all_button; + + let show_less_node = if self.can_show_more { + show_less + .as_widget_mut() + .layout(&mut tree_children[0], renderer, limits) + } else { + Node::new(Size::default()) + }; + let clear_all_node = if self.can_show_more { + let mut n = + clear_all + .as_widget_mut() + .layout(&mut tree_children[1], renderer, limits); + let clear_all_node_size = n.size(); + n = clear_all_node + .translate(Vector::new(size.width - clear_all_node_size.width, 0.0)); + size.height += show_less_node.size().height.max(n.size().height) + VERTICAL_SPACING; + n + } else { + Node::new(Size::default()) + }; + + children.push(show_less_node); + children.push(clear_all_node); + } + + let custom_limits = limits + .min_width(size.width) + .max_width(size.width) + .width(Length::Fixed(size.width)); + + for (i, (c, t)) in self + .elements + .iter_mut() + .zip(tree_children[2..].iter_mut()) + .enumerate() + { + let progress = s * size.height; + let card_node = c + .as_widget_mut() + .layout(t, renderer, &custom_limits) + .translate(Vector::new(0.0, progress)); + + size.height = size.height.max(progress + card_node.size().height); + + children.push(card_node); + + if fully_unexpanded { + let width = children.last().unwrap().bounds().width; + + // push the background card nodes + for i in 1..self.elements.len().min(3) { + // height must be 16px for 8px padding + // but we only want 4px visible + + let margin = f32::from(u8::try_from(i).unwrap()) * BG_CARD_MARGIN_STEP; + let node = + Node::new(Size::new(width - 2.0 * margin, BG_CARD_BORDER_RADIUS * 2.0)) + .translate(Vector::new( + margin, + size.height - BG_CARD_BORDER_RADIUS * 2.0 + BG_CARD_VISIBLE_HEIGHT, + )); + size.height += BG_CARD_VISIBLE_HEIGHT; + children.push(node); + } + break; + } + + if i + 1 < count { + size.height += VERTICAL_SPACING; + } + } + + Node::with_children(size, children) + } + + fn draw( + &self, + state: &iced_core::widget::Tree, + renderer: &mut Renderer, + theme: &crate::Theme, + style: &iced_core::renderer::Style, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + viewport: &iced_core::Rectangle, + ) { + let my_state = state.state.downcast_ref::(); + + // there are 4 cases for drawing + // 1. empty entries list + // Nothing to draw + // 2. un-expanded + // go through the layout, draw the card, the inner card, and the bg cards + // 3. expanding / unexpanding + // go through the layout. draw each card and its inner card + // 4. expanded => + // go through the layout. draw the top bar, and do all of 3 + // cards may be hovered + // any buttons may have a hover state as well + if self.elements.is_empty() { + return; + } + + let t = my_state.anim.t(self.duration, self.expanded); + let fully_unexpanded = self.fully_unexpanded(t); + let fully_expanded = self.fully_expanded(t); + + let mut layout = layout.children(); + let mut tree_children = state.children.iter(); + + if fully_expanded { + let show_less = &self.show_less_button; + let clear_all = &self.clear_all_button; + + let show_less_layout = layout.next().unwrap(); + let clear_all_layout = layout.next().unwrap(); + + show_less.as_widget().draw( + tree_children.next().unwrap(), + renderer, + theme, + style, + show_less_layout, + cursor, + viewport, + ); + + clear_all.as_widget().draw( + tree_children.next().unwrap(), + renderer, + theme, + style, + clear_all_layout, + cursor, + viewport, + ); + } else { + _ = tree_children.next(); + _ = tree_children.next(); + } + + // Draw first to appear behind + if fully_unexpanded { + let card_layout = layout.next().unwrap(); + let appearance = Style::default(); + let bg_layout = layout.collect::>(); + for (i, layout) in (0..2).zip(bg_layout.into_iter()).rev() { + renderer.fill_quad( + Quad { + bounds: layout.bounds(), + border: Border { + radius: Radius::from([ + 0.0, + 0.0, + BG_CARD_BORDER_RADIUS, + BG_CARD_BORDER_RADIUS, + ]), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }, + if i == 0 { + appearance.card_1 + } else { + appearance.card_2 + }, + ); + } + self.elements[0].as_widget().draw( + tree_children.next().unwrap(), + renderer, + theme, + style, + card_layout, + cursor, + viewport, + ); + } else { + let layout = layout.collect::>(); + // draw in reverse order so later cards appear behind earlier cards + for ((inner, layout), c_state) in self + .elements + .iter() + .rev() + .zip(layout.into_iter().rev()) + .zip(tree_children.rev()) + { + inner + .as_widget() + .draw(c_state, renderer, theme, style, layout, cursor, viewport); + } + } + } + + fn update( + &mut self, + state: &mut Tree, + event: &iced_core::Event, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn iced_core::Clipboard, + shell: &mut iced_core::Shell<'_, Message>, + viewport: &iced_core::Rectangle, + ) { + if self.elements.is_empty() { + return; + } + + if let Event::Window(window::Event::RedrawRequested(_)) = event { + let state = state.state.downcast_mut::(); + + state.anim.anim_done(self.duration); + if state.anim.last_change.is_some() { + shell.request_redraw(); + shell.invalidate_layout(); + } + } + + let my_state = state.state.downcast_ref::(); + + let mut layout = layout.children(); + let mut tree_children = state.children.iter_mut(); + let t = my_state.anim.t(self.duration, self.expanded); + let fully_expanded = self.fully_expanded(t); + let fully_unexpanded = self.fully_unexpanded(t); + let show_less_state = tree_children.next(); + let clear_all_state = tree_children.next(); + + if fully_expanded { + let c_layout = layout.next().unwrap(); + let state = show_less_state.unwrap(); + self.show_less_button.as_widget_mut().update( + state, event, c_layout, cursor, renderer, clipboard, shell, viewport, + ); + + if shell.is_event_captured() { + return; + } + + let c_layout = layout.next().unwrap(); + let state = clear_all_state.unwrap(); + self.clear_all_button.as_widget_mut().update( + state, &event, c_layout, cursor, renderer, clipboard, shell, viewport, + ); + } + + if shell.is_event_captured() { + return; + } + + for ((inner, layout), c_state) in self.elements.iter_mut().zip(layout).zip(tree_children) { + inner.as_widget_mut().update( + c_state, &event, layout, cursor, renderer, clipboard, shell, viewport, + ); + if shell.is_event_captured() || fully_unexpanded { + break; + } + } + } + + fn size(&self) -> Size { + Size::new(self.width, Length::Shrink) + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn set_id(&mut self, id: Id) { + self.id = id; + } +} + +impl<'a, Message> From> for Element<'a, Message, crate::Theme, crate::Renderer> +where + Message: Clone + 'a, +{ + fn from(cards: Cards<'a, Message>) -> Self { + Self::new(cards) + } +} + +#[derive(Debug, Default)] +pub struct State { + anim: anim::State, +} diff --git a/src/widget/color_picker/mod.rs b/src/widget/color_picker/mod.rs index d7f42adf..318e943b 100644 --- a/src/widget/color_picker/mod.rs +++ b/src/widget/color_picker/mod.rs @@ -5,50 +5,69 @@ use std::borrow::Cow; use std::rc::Rc; +use std::sync::LazyLock; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; +use crate::Element; use crate::theme::iced::Slider; use crate::theme::{Button, THEME}; -use crate::widget::{container, segmented_button::Entity, slider}; -use crate::Element; +use crate::widget::{button::Catalog, container, segmented_button::Entity, slider}; use derive_setters::Setters; -use iced::Command; +use iced::Task; use iced_core::event::{self, Event}; use iced_core::gradient::{ColorStop, Linear}; use iced_core::renderer::Quad; -use iced_core::widget::{tree, Tree}; +use iced_core::widget::{Tree, tree}; use iced_core::{ - layout, mouse, renderer, Background, Border, Clipboard, Color, Layout, Length, Radians, - Rectangle, Renderer, Shadow, Shell, Size, Vector, Widget, + Background, Border, Clipboard, Color, Layout, Length, Radians, Rectangle, Renderer, Shadow, + Shell, Size, Vector, Widget, layout, mouse, renderer, }; -use iced_style::slider::{HandleShape, RailBackground}; -use iced_widget::{canvas, column, horizontal_space, row, scrollable, vertical_space, Row}; -use lazy_static::lazy_static; +use iced_widget::slider::HandleShape; +use iced_widget::{ + Row, canvas, column, row, scrollable, + space::{horizontal, vertical}, +}; use palette::{FromColor, RgbHue}; -use super::button::StyleSheet; use super::divider::horizontal; use super::icon::{self, from_name}; use super::segmented_button::{self, SingleSelect}; -use super::{button, segmented_control, text, text_input, tooltip, Icon}; +use super::{Icon, button, segmented_control, text, text_input, tooltip}; #[doc(inline)] pub use ColorPickerModel as Model; // TODO is this going to look correct enough? -lazy_static! { - pub static ref HSV_RAINBOW: Vec = (0u16..8) - .map(|h| ColorStop { - color: iced::Color::from(palette::Srgba::from_color(palette::Hsv::new_srgb_const( +pub static HSV_RAINBOW: LazyLock> = LazyLock::new(|| { + (0u16..8) + .map(|h| { + Color::from(palette::Srgba::from_color(palette::Hsv::new_srgb_const( RgbHue::new(f32::from(h) * 360.0 / 7.0), 1.0, - 1.0 - ))), - offset: f32::from(h) / 7.0 + 1.0, + ))) }) - .collect(); + .collect() +}); + +fn hsv_rainbow(low_hue: f32, high_hue: f32) -> Vec { + let mut colors = Vec::new(); + let steps: u8 = 7; + let step_size = (high_hue - low_hue) / f32::from(steps); + for i in 0..=steps { + let hue = low_hue + step_size * f32::from(i); + colors.push(ColorStop { + color: Color::from(palette::Srgba::from_color(palette::Hsv::new_srgb_const( + RgbHue::new(hue), + 1.0, + 1.0, + ))), + offset: f32::from(i) / f32::from(steps), + }); + } + colors } const MAX_RECENT: usize = 20; @@ -73,8 +92,6 @@ pub struct ColorPickerModel { #[setters(skip)] active_color: palette::Hsv, #[setters(skip)] - save_next: Option, - #[setters(skip)] input_color: String, #[setters(skip)] applied_color: Option, @@ -108,7 +125,6 @@ impl ColorPickerModel { .insert(move |b| b.text(rgb.clone())) .build(), active_color: hsv, - save_next: None, input_color: color_to_string(hsv, true), applied_color: initial, fallback_color, @@ -123,7 +139,11 @@ impl ColorPickerModel { /// Get a color picker button that displays the applied color /// - pub fn picker_button<'a, Message: 'static, T: Fn(ColorPickerUpdate) -> Message>( + pub fn picker_button< + 'a, + Message: 'static + std::clone::Clone, + T: Fn(ColorPickerUpdate) -> Message, + >( &self, f: T, icon_portion: Option, @@ -135,22 +155,26 @@ impl ColorPickerModel { ) } - pub fn update(&mut self, update: ColorPickerUpdate) -> Command { + fn update_recent_colors(&mut self, new_color: Color) { + if let Some(pos) = self.recent_colors.iter().position(|c| *c == new_color) { + self.recent_colors.remove(pos); + } + self.recent_colors.insert(0, new_color); + self.recent_colors.truncate(MAX_RECENT); + } + + pub fn update(&mut self, update: ColorPickerUpdate) -> Task { match update { ColorPickerUpdate::ActiveColor(c) => { self.must_clear_cache.store(true, Ordering::SeqCst); self.input_color = color_to_string(c, self.is_hex()); - if let Some(to_save) = self.save_next.take() { - self.recent_colors.insert(0, to_save); - self.recent_colors.truncate(MAX_RECENT); - } self.active_color = c; self.copied_at = None; } - ColorPickerUpdate::AppliedColor => { + ColorPickerUpdate::AppliedColor | ColorPickerUpdate::ActionFinished => { let srgb = palette::Srgb::from_color(self.active_color); if let Some(applied_color) = self.applied_color.take() { - self.recent_colors.push(applied_color); + self.update_recent_colors(applied_color); } self.applied_color = Some(Color::from(srgb)); self.active = false; @@ -191,22 +215,13 @@ impl ColorPickerModel { palette::Hsv::from_color(palette::Srgb::new(c.red, c.green, c.blue)); } } - ColorPickerUpdate::ActionFinished => { - let srgb = palette::Srgb::from_color(self.active_color); - if let Some(applied_color) = self.applied_color.take() { - self.recent_colors.push(applied_color); - } - self.applied_color = Some(Color::from(srgb)); - self.active = false; - self.save_next = Some(Color::from(srgb)); - } ColorPickerUpdate::ToggleColorPicker => { self.must_clear_cache.store(true, Ordering::SeqCst); self.active = !self.active; self.copied_at = None; } - }; - Command::none() + } + Task::none() } #[must_use] @@ -230,7 +245,7 @@ impl ColorPickerModel { pub fn builder( &self, on_update: fn(ColorPickerUpdate) -> Message, - ) -> ColorPickerBuilder { + ) -> ColorPickerBuilder<'_, Message> { ColorPickerBuilder { model: &self.segmented_model, active_color: self.active_color, @@ -286,8 +301,23 @@ where copy_to_clipboard_label: T, copied_to_clipboard_label: T, ) -> ColorPicker<'a, Message> { + fn rail_backgrounds(hue: f32) -> (Background, Background) { + let low_range = hsv_rainbow(0., hue); + let high_range = hsv_rainbow(hue, 360.); + + ( + Background::Gradient(iced::Gradient::Linear( + Linear::new(Radians(90.0)).add_stops(low_range), + )), + Background::Gradient(iced::Gradient::Linear( + Linear::new(Radians(90.0)).add_stops(high_range), + )), + ) + } + let on_update = self.on_update; let spacing = THEME.lock().unwrap().cosmic().spacing; + let mut inner = column![ // segmented buttons segmented_control::horizontal(self.model) @@ -298,11 +328,11 @@ where .width(self.width), // canvas with gradient for the current color // still needs the canvas and the handle to be drawn on it - container(vertical_space(self.height)) + container(vertical().height(self.height)) .width(self.width) .height(self.height), slider( - 0.0..=359.99, + 0.001..=359.99, self.active_color.hue.into_positive_degrees(), move |v| { let mut new = self.active_color; @@ -310,44 +340,42 @@ where on_update(ColorPickerUpdate::ActiveColor(new)) } ) - .style(Slider::Custom { - active: Rc::new(|t| { + .on_release(on_update(ColorPickerUpdate::ActionFinished)) + .class(Slider::Custom { + active: Rc::new(move |t| { let cosmic = t.cosmic(); - let mut a = slider::StyleSheet::active(t, &Slider::default()); - a.rail.colors = RailBackground::Gradient { - gradient: Linear::new(Radians(0.0)).add_stops(HSV_RAINBOW.clone()), - auto_angle: true, - }; + let mut a = + slider::Catalog::style(t, &Slider::default(), slider::Status::Active); + let hue = self.active_color.hue.into_positive_degrees(); + a.rail.backgrounds = rail_backgrounds(hue); a.rail.width = 8.0; - a.handle.color = Color::TRANSPARENT; + a.handle.background = Color::TRANSPARENT.into(); a.handle.shape = HandleShape::Circle { radius: 8.0 }; a.handle.border_color = cosmic.palette.neutral_10.into(); a.handle.border_width = 4.0; a }), - hovered: Rc::new(|t| { + hovered: Rc::new(move |t| { let cosmic = t.cosmic(); - let mut a = slider::StyleSheet::active(t, &Slider::default()); - a.rail.colors = RailBackground::Gradient { - gradient: Linear::new(Radians(0.0)).add_stops(HSV_RAINBOW.clone()), - auto_angle: true, - }; + let mut a = + slider::Catalog::style(t, &Slider::default(), slider::Status::Active); + let hue = self.active_color.hue.into_positive_degrees(); + a.rail.backgrounds = rail_backgrounds(hue); a.rail.width = 8.0; - a.handle.color = Color::TRANSPARENT; + a.handle.background = Color::TRANSPARENT.into(); a.handle.shape = HandleShape::Circle { radius: 8.0 }; a.handle.border_color = cosmic.palette.neutral_10.into(); a.handle.border_width = 4.0; a }), - dragging: Rc::new(|t| { + dragging: Rc::new(move |t| { let cosmic = t.cosmic(); - let mut a = slider::StyleSheet::active(t, &Slider::default()); - a.rail.colors = RailBackground::Gradient { - gradient: Linear::new(Radians(0.0)).add_stops(HSV_RAINBOW.clone()), - auto_angle: true, - }; + let mut a = + slider::Catalog::style(t, &Slider::default(), slider::Status::Active); + let hue = self.active_color.hue.into_positive_degrees(); + a.rail.backgrounds = rail_backgrounds(hue); a.rail.width = 8.0; - a.handle.color = Color::TRANSPARENT; + a.handle.background = Color::TRANSPARENT.into(); a.handle.shape = HandleShape::Circle { radius: 8.0 }; a.handle.border_color = cosmic.palette.neutral_10.into(); a.handle.border_width = 4.0; @@ -358,7 +386,8 @@ where text_input("", self.input_color) .on_input(move |s| on_update(ColorPickerUpdate::Input(s))) .on_paste(move |s| on_update(ColorPickerUpdate::Input(s))) - .on_submit(on_update(ColorPickerUpdate::AppliedColor)) + .on_submit(move |_| on_update(ColorPickerUpdate::ActionFinished)) + // .on_unfocus(on_update(ColorPickerUpdate::ActionFinished)) Somehow this is called even when the field wasn't previously focused .leading_icon( color_button( None, @@ -373,7 +402,7 @@ where from_name("edit-copy-symbolic").size(spacing.space_s).into(), )) .on_press(on_update(ColorPickerUpdate::Copied(Instant::now()))) - .style(Button::Text); + .class(Button::Text); match self.copied_at.take() { Some(t) if Instant::now().duration_since(t) > Duration::from_secs(2) => { @@ -381,13 +410,13 @@ where } Some(_) => tooltip( button, - copied_to_clipboard_label, + text(copied_to_clipboard_label), iced_widget::tooltip::Position::Bottom, ) .into(), None => tooltip( button, - copy_to_clipboard_label, + text(copy_to_clipboard_label), iced_widget::tooltip::Position::Bottom, ) .into(), @@ -411,27 +440,22 @@ where // TODO get global colors from some cache? // TODO how to handle overflow? should this use a grid widget for the list or a horizontal scroll and a limit for the max? crate::widget::scrollable( - Row::with_children( - self.recent_colors - .iter() - .map(|c| { - let initial_srgb = palette::Srgb::from(*c); - let hsv = palette::Hsv::from_color(initial_srgb); - color_button( - Some(on_update(ColorPickerUpdate::ActiveColor(hsv))), - Some(*c), - Length::FillPortion(12), - ) - .into() - }) - .collect::>(), - ) + Row::with_children(self.recent_colors.iter().map(|c| { + let initial_srgb = palette::Srgb::from(*c); + let hsv = palette::Hsv::from_color(initial_srgb); + color_button( + Some(on_update(ColorPickerUpdate::ActiveColor(hsv))), + Some(*c), + Length::FillPortion(12), + ) + .into() + })) .padding([0.0, 0.0, f32::from(spacing.space_m), 0.0]) .spacing(spacing.space_xxs), ) .width(self.width) .direction(iced_widget::scrollable::Direction::Horizontal( - scrollable::Properties::new().alignment(scrollable::Alignment::End), + scrollable::Scrollbar::new().anchor(scrollable::Anchor::End), )) }] .spacing(spacing.space_xxs), @@ -445,7 +469,7 @@ where button::custom( text(reset_to_default) .width(self.width) - .horizontal_alignment(iced_core::alignment::Horizontal::Center) + .align_x(iced_core::Alignment::Center) ) .width(self.width) .on_press(on_update(ColorPickerUpdate::Reset)) @@ -461,18 +485,18 @@ where button::custom( text(cancel) .width(self.width) - .horizontal_alignment(iced_core::alignment::Horizontal::Center) + .align_x(iced_core::Alignment::Center) ) .width(self.width) .on_press(on_update(ColorPickerUpdate::Cancel)), button::custom( text(save) .width(self.width) - .horizontal_alignment(iced_core::alignment::Horizontal::Center) + .align_x(iced_core::Alignment::Center) ) .width(self.width) .on_press(on_update(ColorPickerUpdate::AppliedColor)) - .style(Button::Suggested) + .class(Button::Suggested) ] .spacing(spacing.space_xs) .width(self.width), @@ -498,7 +522,7 @@ pub struct ColorPicker<'a, Message> { must_clear_cache: Rc, } -impl<'a, Message> Widget for ColorPicker<'a, Message> +impl Widget for ColorPicker<'_, Message> where Message: Clone + 'static, { @@ -519,13 +543,13 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &crate::Renderer, limits: &layout::Limits, ) -> layout::Node { self.inner - .as_widget() + .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) } @@ -589,7 +613,7 @@ where let translation = Vector::new(canvas_layout.bounds().x, canvas_layout.bounds().y); iced_core::Renderer::with_translation(renderer, translation, |renderer| { - canvas::Renderer::draw(renderer, vec![geo]); + iced_renderer::geometry::Renderer::draw_geometry(renderer, geo); }); let bounds = canvas_layout.bounds(); @@ -628,6 +652,7 @@ where radius: (1.0 + handle_radius).into(), }, shadow: Shadow::default(), + snap: true, }, Color::TRANSPARENT, ); @@ -645,6 +670,7 @@ where radius: handle_radius.into(), }, shadow: Shadow::default(), + snap: true, }, Color::TRANSPARENT, ); @@ -655,25 +681,31 @@ where fn overlay<'b>( &'b mut self, state: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &crate::Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - self.inner - .as_widget_mut() - .overlay(&mut state.children[0], layout, renderer) + self.inner.as_widget_mut().overlay( + &mut state.children[0], + layout, + renderer, + viewport, + translation, + ) } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { + ) { // if the pointer is performing a drag, intercept pointer motion and button events // else check if event is handled by child elements // if the event is not handled by a child element, check if it is over the canvas when pressing a button @@ -702,24 +734,26 @@ where shell.publish((self.on_update)(ColorPickerUpdate::ActionFinished)); state.dragging = false; } - _ => return event::Status::Ignored, + _ => return, }; - return event::Status::Captured; + shell.capture_event(); + return; } let column_tree = &mut tree.children[0]; - if self.inner.as_widget_mut().on_event( + self.inner.as_widget_mut().update( column_tree, - event.clone(), + &event, column_layout, cursor, renderer, clipboard, shell, viewport, - ) == event::Status::Captured - { - return event::Status::Captured; + ); + if shell.is_event_captured() { + shell.capture_event(); + return; } match event { @@ -734,12 +768,10 @@ where state.dragging = true; let hsv: palette::Hsv = palette::Hsv::new(self.active_color.hue, s, v); shell.publish((self.on_update)(ColorPickerUpdate::ActiveColor(hsv))); - event::Status::Captured - } else { - event::Status::Ignored + shell.capture_event(); } } - _ => event::Status::Ignored, + _ => {} } } @@ -760,7 +792,7 @@ impl State { } } -impl<'a, Message> ColorPicker<'a, Message> where Message: Clone + 'static {} +impl ColorPicker<'_, Message> where Message: Clone + 'static {} // TODO convert active color to hex or rgba fn color_to_string(c: palette::Hsv, is_hex: bool) -> String { let srgb = palette::Srgb::from_color(c); @@ -774,7 +806,7 @@ fn color_to_string(c: palette::Hsv, is_hex: bool) -> String { #[allow(clippy::too_many_lines)] /// A button for selecting a color from a color picker. -pub fn color_button<'a, Message: 'static>( +pub fn color_button<'a, Message: Clone + 'static>( on_press: Option, color: Option, icon_portion: Length, @@ -782,12 +814,12 @@ pub fn color_button<'a, Message: 'static>( let spacing = THEME.lock().unwrap().cosmic().spacing; button::custom(if color.is_some() { - Element::from(vertical_space(Length::Fixed(f32::from(spacing.space_s)))) + Element::from(vertical().height(Length::Fixed(f32::from(spacing.space_s)))) } else { Element::from(column![ - vertical_space(Length::FillPortion(6)), + vertical().height(Length::FillPortion(6)), row![ - horizontal_space(Length::FillPortion(6)), + horizontal().width(Length::FillPortion(6)), Icon::from( icon::from_name("list-add-symbolic") .prefer_svg(true) @@ -797,17 +829,17 @@ pub fn color_button<'a, Message: 'static>( .width(icon_portion) .height(Length::Fill) .content_fit(iced_core::ContentFit::Contain), - horizontal_space(Length::FillPortion(6)), + horizontal().width(Length::FillPortion(6)), ] .height(icon_portion) .width(Length::Fill), - vertical_space(Length::FillPortion(6)), + vertical().height(Length::FillPortion(6)), ]) }) .width(Length::Fixed(f32::from(spacing.space_s))) .height(Length::Fixed(f32::from(spacing.space_s))) .on_press_maybe(on_press) - .style(crate::theme::Button::Custom { + .class(crate::theme::Button::Custom { active: Box::new(move |focused, theme| { let cosmic = theme.cosmic(); @@ -817,12 +849,12 @@ pub fn color_button<'a, Message: 'static>( (0.0, Color::TRANSPARENT) }; let standard = theme.active(focused, false, &Button::Standard); - button::Appearance { + button::Style { shadow_offset: Vector::default(), background: color.map(Background::from).or(standard.background), border_radius: cosmic.radius_xs().into(), border_width: 1.0, - border_color: cosmic.on_bg_color().into(), + border_color: cosmic.palette.neutral_8.into(), outline_width, outline_color, icon_color: None, @@ -834,12 +866,12 @@ pub fn color_button<'a, Message: 'static>( let cosmic = theme.cosmic(); let standard = theme.disabled(&Button::Standard); - button::Appearance { + button::Style { shadow_offset: Vector::default(), background: color.map(Background::from).or(standard.background), border_radius: cosmic.radius_xs().into(), border_width: 1.0, - border_color: cosmic.on_bg_color().into(), + border_color: cosmic.palette.neutral_8.into(), outline_width: 0.0, outline_color: Color::TRANSPARENT, icon_color: None, @@ -857,12 +889,12 @@ pub fn color_button<'a, Message: 'static>( }; let standard = theme.hovered(focused, false, &Button::Standard); - button::Appearance { + button::Style { shadow_offset: Vector::default(), background: color.map(Background::from).or(standard.background), border_radius: cosmic.radius_xs().into(), border_width: 1.0, - border_color: cosmic.on_bg_color().into(), + border_color: cosmic.palette.neutral_8.into(), outline_width, outline_color, icon_color: None, @@ -880,12 +912,12 @@ pub fn color_button<'a, Message: 'static>( }; let standard = theme.pressed(focused, false, &Button::Standard); - button::Appearance { + button::Style { shadow_offset: Vector::default(), background: color.map(Background::from).or(standard.background), border_radius: cosmic.radius_xs().into(), border_width: 1.0, - border_color: cosmic.on_bg_color().into(), + border_color: cosmic.palette.neutral_8.into(), outline_width, outline_color, icon_color: None, diff --git a/src/widget/context_drawer/mod.rs b/src/widget/context_drawer/mod.rs index 52b9a007..107c1ff5 100644 --- a/src/widget/context_drawer/mod.rs +++ b/src/widget/context_drawer/mod.rs @@ -6,13 +6,18 @@ mod overlay; mod widget; +use std::borrow::Cow; + pub use widget::ContextDrawer; use crate::Element; /// An overlayed widget that attaches a toggleable context drawer to the view. pub fn context_drawer<'a, Message: Clone + 'static, Content, Drawer>( - header: &'a str, + title: Option>, + actions: Option>, + header: Option>, + footer: Option>, on_close: Message, content: Content, drawer: Drawer, @@ -22,5 +27,7 @@ where Content: Into>, Drawer: Into>, { - ContextDrawer::new(header, content, drawer, on_close, max_width) + ContextDrawer::new( + title, actions, header, footer, content, drawer, on_close, max_width, + ) } diff --git a/src/widget/context_drawer/overlay.rs b/src/widget/context_drawer/overlay.rs index 434bf8db..39b34217 100644 --- a/src/widget/context_drawer/overlay.rs +++ b/src/widget/context_drawer/overlay.rs @@ -4,41 +4,36 @@ use crate::Element; use iced::advanced::layout::{self, Layout}; -use iced::advanced::widget::{self, Operation, OperationOutputWrapper}; -use iced::advanced::{overlay, renderer}; +use iced::advanced::widget::{self, Operation}; use iced::advanced::{Clipboard, Shell}; -use iced::{event, mouse, Event, Point, Rectangle, Size}; -use iced_core::Renderer; +use iced::advanced::{overlay, renderer}; +use iced::{Event, Point, Size, mouse}; +use iced_core::{Renderer, touch}; pub(super) struct Overlay<'a, 'b, Message> { + pub(crate) position: Point, pub(super) content: &'b mut Element<'a, Message>, pub(super) tree: &'b mut widget::Tree, pub(super) width: f32, } -impl<'a, 'b, Message> overlay::Overlay - for Overlay<'a, 'b, Message> +impl overlay::Overlay for Overlay<'_, '_, Message> where Message: Clone, { - fn layout( - &mut self, - renderer: &crate::Renderer, - bounds: Size, - position: Point, - _translation: iced::Vector, - ) -> layout::Node { + fn layout(&mut self, renderer: &crate::Renderer, bounds: Size) -> layout::Node { + let position = self.position; let limits = layout::Limits::new(Size::ZERO, bounds) .width(self.width) .height(bounds.height - 8.0 - position.y); let node = self .content - .as_widget() + .as_widget_mut() .layout(self.tree, renderer, &limits); let node_size = node.size(); - node.clone().move_to(Point { + node.move_to(Point { x: if bounds.width > node_size.width - 8.0 { bounds.width - node_size.width - 8.0 } else { @@ -52,16 +47,16 @@ where }) } - fn on_event( + fn update( &mut self, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - ) -> event::Status { - self.content.as_widget_mut().on_event( + ) { + self.content.as_widget_mut().update( self.tree, event, layout, @@ -70,7 +65,20 @@ where clipboard, shell, &layout.bounds(), - ) + ); + match event { + Event::Mouse(e) if !matches!(e, mouse::Event::CursorLeft) => { + if cursor.is_over(layout.bounds()) { + shell.capture_event(); + } + } + Event::Touch(e) if !matches!(e, touch::Event::FingerLost { .. }) => { + if cursor.is_over(layout.bounds()) { + shell.capture_event(); + } + } + _ => {} + } } fn draw( @@ -91,14 +99,14 @@ where cursor, &layout.bounds(), ); - }) + }); } fn operate( &mut self, layout: Layout<'_>, renderer: &crate::Renderer, - operation: &mut dyn Operation>, + operation: &mut dyn Operation<()>, ) { self.content .as_widget_mut() @@ -109,21 +117,35 @@ where &self, layout: Layout<'_>, cursor: mouse::Cursor, - viewport: &Rectangle, renderer: &crate::Renderer, ) -> mouse::Interaction { - self.content + // TODO how to handle viewport here? + let viewport = &layout.bounds(); + let interaction = self + .content .as_widget() - .mouse_interaction(self.tree, layout, cursor, viewport, renderer) + .mouse_interaction(self.tree, layout, cursor, viewport, renderer); + if let mouse::Interaction::None = interaction + && cursor.is_over(layout.bounds()) + { + return mouse::Interaction::Idle; + } + interaction } fn overlay<'c>( &'c mut self, - layout: Layout<'_>, + layout: Layout<'c>, renderer: &crate::Renderer, ) -> Option> { - self.content - .as_widget_mut() - .overlay(self.tree, layout, renderer) + let viewport = &layout.bounds(); + + self.content.as_widget_mut().overlay( + self.tree, + layout, + renderer, + viewport, + iced::Vector::default(), + ) } } diff --git a/src/widget/context_drawer/widget.rs b/src/widget/context_drawer/widget.rs index 417885bc..7420738c 100644 --- a/src/widget/context_drawer/widget.rs +++ b/src/widget/context_drawer/widget.rs @@ -1,23 +1,19 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use crate::widget::{ - button, column, container, icon, row, scrollable, text, LayerContainer, Space, -}; -use crate::{Apply, Element, Renderer, Theme}; - use super::overlay::Overlay; +use crate::widget::{self, LayerContainer, button, column, container, icon, row, scrollable, text}; +use crate::{Apply, Element, Renderer, Theme, fl}; +use std::borrow::Cow; -use iced_core::alignment; -use iced_core::event::{self, Event}; +use iced_core::Alignment; +use iced_core::event::Event; use iced_core::widget::{Operation, Tree}; use iced_core::{ - layout, mouse, overlay as iced_overlay, renderer, Clipboard, Layout, Length, Padding, - Rectangle, Shell, Widget, + Clipboard, Layout, Length, Rectangle, Shell, Vector, Widget, layout, mouse, + overlay as iced_overlay, renderer, }; -use iced_renderer::core::widget::OperationOutputWrapper; - #[must_use] pub struct ContextDrawer<'a, Message> { id: Option, @@ -28,7 +24,10 @@ pub struct ContextDrawer<'a, Message> { impl<'a, Message: Clone + 'static> ContextDrawer<'a, Message> { pub fn new_inner( - header: &'a str, + title: Option>, + actions: Option>, + header: Option>, + footer: Option>, drawer: Drawer, on_close: Message, max_width: f32, @@ -36,68 +35,105 @@ impl<'a, Message: Clone + 'static> ContextDrawer<'a, Message> { where Drawer: Into>, { - let header = row::with_capacity(3) - .padding(Padding { - top: 0.0, - bottom: 0.0, - left: 32.0, - right: 32.0, - }) - .push(Space::new(Length::FillPortion(1), Length::Fixed(0.0))) - .push( - text::heading(header) - .width(Length::FillPortion(1)) - .height(Length::Fill) - .horizontal_alignment(alignment::Horizontal::Center) - .vertical_alignment(alignment::Vertical::Center), - ) - .push( - button::text("Close") - .trailing_icon(icon::from_name("go-next-symbolic")) - .on_press(on_close) - .style(crate::theme::Button::Link) + #[inline(never)] + fn inner<'a, Message: Clone + 'static>( + title: Option>, + actions_opt: Option>, + header_opt: Option>, + footer_opt: Option>, + drawer: Element<'a, Message>, + on_close: Message, + max_width: f32, + ) -> Element<'a, Message> { + let cosmic_theme::Spacing { + space_xxs, + space_s, + space_m, + space_l, + .. + } = crate::theme::spacing(); + + let horizontal_padding = if max_width < 392.0 { space_s } else { space_l }; + + let (actions_slot, column_title) = if let Some(actions) = actions_opt { + let actions = actions .apply(container) - .width(Length::FillPortion(1)) - .height(Length::Fill) - .align_x(alignment::Horizontal::Right) - .center_y(), - ) - // XXX must be done after pushing elements or it may be overwritten by size hints from contents - .height(Length::Fixed(80.0)) - .width(Length::Fixed(480.0)); + .width(Length::Fill) + .apply(Element::from); + let title = title.map(|title| text::title4(title).width(Length::Fill)); + (actions, title) + } else { + let title = title + .map(|title| text::title4(title).width(Length::Fill).apply(Element::from)) + .unwrap_or_else(|| widget::space::horizontal().apply(Element::from)); + (title, None) + }; - let pane = column::with_capacity(2) - .push(header.height(Length::Fixed(80.))) - .push( - scrollable(container(drawer.into()).padding(Padding { - top: 0.0, - left: 32.0, - right: 32.0, - bottom: 32.0, - })) - .height(Length::Fill) - .width(Length::Shrink), + let header_row = row::with_capacity(2).push(actions_slot).push( + button::text(fl!("close")) + .trailing_icon(icon::from_name("go-next-symbolic")) + .on_press(on_close), ); + let header = column::with_capacity(3) + .align_x(Alignment::Center) + .padding([space_m, horizontal_padding]) + .spacing(space_m) + .push(header_row) + .push_maybe(column_title) + .push_maybe(header_opt); + let footer = footer_opt.map(|element| { + container(element) + .align_y(Alignment::Center) + .padding([space_xxs, horizontal_padding]) + }); + let pane = column::with_capacity(3) + .push(header) + .push( + container(drawer) + .padding([ + 0, + horizontal_padding, + if footer.is_some() { 0 } else { space_l }, + horizontal_padding, + ]) + .apply(scrollable) + .height(Length::Fill), + ) + .push_maybe(footer); - // XXX new limits do not exactly handle the max width well for containers - // XXX this is a hack to get around that - container( - LayerContainer::new(pane) - .layer(cosmic_theme::Layer::Primary) - .style(crate::style::Container::ContextDrawer) - .width(Length::Fill) - .height(Length::Fill) - .max_width(max_width), + // XXX new limits do not exactly handle the max width well for containers + // XXX this is a hack to get around that + container( + LayerContainer::new(pane) + .layer(cosmic_theme::Layer::Primary) + .class(crate::style::Container::ContextDrawer) + .width(Length::Fill) + .height(Length::Fill) + .max_width(max_width), + ) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::End) + .into() + } + + inner( + title, + actions, + header, + footer, + drawer.into(), + on_close, + max_width, ) - .width(Length::Fill) - .height(Length::Fill) - .align_x(alignment::Horizontal::Right) - .into() } /// Creates an empty [`ContextDrawer`]. pub fn new( - header: &'a str, + title: Option>, + actions: Option>, + header: Option>, + footer: Option>, content: Content, drawer: Drawer, on_close: Message, @@ -107,7 +143,7 @@ impl<'a, Message: Clone + 'static> ContextDrawer<'a, Message> { Content: Into>, Drawer: Into>, { - let drawer = Self::new_inner(header, drawer, on_close, max_width); + let drawer = Self::new_inner(title, actions, header, footer, drawer, on_close, max_width); ContextDrawer { id: None, @@ -118,19 +154,35 @@ impl<'a, Message: Clone + 'static> ContextDrawer<'a, Message> { } /// Sets the [`Id`] of the [`ContextDrawer`]. + #[inline] pub fn id(mut self, id: iced_core::widget::Id) -> Self { self.id = Some(id); self } - // Optionally assigns message to `on_close` event. + /// Map the message type of the context drawer to another + #[inline] + pub fn map( + self, + on_message: fn(Message) -> Out, + ) -> ContextDrawer<'a, Out> { + ContextDrawer { + id: self.id, + content: self.content.map(on_message), + drawer: self.drawer.map(on_message), + on_close: self.on_close.map(on_message), + } + } + + /// Optionally assigns message to `on_close` event. + #[inline] pub fn on_close_maybe(mut self, message: Option) -> Self { self.on_close = message; self } } -impl<'a, Message: Clone> Widget for ContextDrawer<'a, Message> { +impl Widget for ContextDrawer<'_, Message> { fn children(&self) -> Vec { vec![Tree::new(&self.content), Tree::new(&self.drawer)] } @@ -144,40 +196,40 @@ impl<'a, Message: Clone> Widget for ContextDraw } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { self.content - .as_widget() + .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>, + operation: &mut dyn Operation<()>, ) { self.content - .as_widget() + .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { - self.content.as_widget_mut().on_event( + ) { + self.content.as_widget_mut().update( &mut tree.children[0], event, layout, @@ -186,7 +238,7 @@ impl<'a, Message: Clone> Widget for ContextDraw clipboard, shell, viewport, - ) + ); } fn mouse_interaction( @@ -230,19 +282,23 @@ impl<'a, Message: Clone> Widget for ContextDraw fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, _renderer: &Renderer, + _viewport: &Rectangle, + translation: Vector, ) -> Option> { let bounds = layout.bounds(); - Some(iced_overlay::Element::new( - layout.position(), - Box::new(Overlay { - content: &mut self.drawer, - tree: &mut tree.children[1], - width: bounds.width, - }), - )) + let mut position = layout.position(); + position.x += translation.x; + position.y += translation.y; + + Some(iced_overlay::Element::new(Box::new(Overlay { + content: &mut self.drawer, + tree: &mut tree.children[1], + width: bounds.width, + position, + }))) } #[cfg(feature = "a11y")] @@ -253,9 +309,8 @@ impl<'a, Message: Clone> Widget for ContextDraw 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, c_state, p) + self.content.as_widget().a11y_nodes(layout, c_state, p) } fn drag_destinations( diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index 261779f6..3f35f04a 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -3,30 +3,42 @@ //! A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation. +#[cfg(all( + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" +))] +use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem}; use crate::widget::menu::{ - self, CloseCondition, ItemHeight, ItemWidth, MenuBarState, PathHighlight, + self, CloseCondition, Direction, ItemHeight, ItemWidth, MenuBarState, PathHighlight, + init_root_menu, menu_roots_diff, }; use derive_setters::Setters; use iced::touch::Finger; -use iced::Event; -use iced_core::widget::{tree, Tree, Widget}; -use iced_core::{event, mouse, touch, Length, Point, Size}; +use iced::{Event, Vector, keyboard, window}; +use iced_core::widget::{Tree, Widget, tree}; +use iced_core::{Length, Point, Size, mouse, touch}; use std::collections::HashSet; +use std::sync::Arc; /// A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation. -pub fn context_menu<'a, Message: 'a>( - content: impl Into> + 'a, +pub fn context_menu<'a, Message: 'static + Clone>( + content: impl Into>, // on_context: Message, - context_menu: Option>>, + context_menu: Option>>, ) -> ContextMenu<'a, Message> { let mut this = ContextMenu { content: content.into(), context_menu: context_menu.map(|menus| { vec![menu::Tree::with_children( - crate::widget::row::<'static, Message>(), + crate::Element::from(crate::widget::Row::new()), menus, )] }), + close_on_escape: true, + window_id: window::Id::RESERVED, + on_surface_action: None, }; if let Some(ref mut context_menu) = this.context_menu { @@ -43,11 +55,168 @@ pub struct ContextMenu<'a, Message> { #[setters(skip)] content: crate::Element<'a, Message>, #[setters(skip)] - context_menu: Option>>, + context_menu: Option>>, + pub window_id: window::Id, + pub close_on_escape: bool, + #[setters(skip)] + pub(crate) on_surface_action: + Option Message + Send + Sync + 'static>>, } -impl<'a, Message: Clone> Widget - for ContextMenu<'a, Message> +impl ContextMenu<'_, Message> { + #[cfg(all( + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + #[allow(clippy::too_many_lines)] + fn create_popup( + &mut self, + layout: iced_core::Layout<'_>, + view_cursor: iced_core::mouse::Cursor, + renderer: &crate::Renderer, + shell: &mut iced_core::Shell<'_, Message>, + viewport: &iced::Rectangle, + my_state: &mut LocalState, + ) { + if self.window_id != window::Id::NONE && self.on_surface_action.is_some() { + use crate::{surface::action::destroy_popup, widget::menu::Menu}; + use iced_runtime::platform_specific::wayland::popup::{ + SctkPopupSettings, SctkPositioner, + }; + + let mut bounds = layout.bounds(); + bounds.x = my_state.context_cursor.x; + bounds.y = my_state.context_cursor.y; + + let (id, root_list) = my_state.menu_bar_state.inner.with_data_mut(|state| { + if let Some(id) = state.popup_id.get(&self.window_id).copied() { + // close existing popups + state.menu_states.clear(); + state.active_root.clear(); + + shell.publish(self.on_surface_action.as_ref().unwrap()(destroy_popup(id))); + state.view_cursor = view_cursor; + ( + id, + layout.children().map(|lo| lo.bounds()).collect::>(), + ) + } else { + ( + window::Id::unique(), + layout.children().map(|lo| lo.bounds()).collect(), + ) + } + }); + let Some(context_menu) = self.context_menu.as_mut() else { + return; + }; + + let mut popup_menu: Menu<'static, _> = Menu { + tree: my_state.menu_bar_state.clone(), + menu_roots: std::borrow::Cow::Owned(context_menu.clone()), + bounds_expand: 16, + menu_overlays_parent: true, + close_condition: CloseCondition { + leave: false, + click_outside: true, + click_inside: true, + }, + item_width: ItemWidth::Uniform(240), + item_height: ItemHeight::Dynamic(40), + bar_bounds: bounds, + main_offset: -(bounds.height as i32), + cross_offset: 0, + root_bounds_list: vec![bounds], + path_highlight: Some(PathHighlight::MenuActive), + style: std::borrow::Cow::Owned(crate::theme::menu_bar::MenuBarStyle::Default), + position: Point::new(0., 0.), + is_overlay: false, + window_id: id, + depth: 0, + on_surface_action: self.on_surface_action.clone(), + }; + + init_root_menu( + &mut popup_menu, + renderer, + shell, + view_cursor.position().unwrap(), + viewport.size(), + Vector::new(0., 0.), + layout.bounds(), + -bounds.height, + ); + let (anchor_rect, gravity) = my_state.menu_bar_state.inner.with_data_mut(|state| { + use iced::Rectangle; + + state.popup_id.insert(self.window_id, id); + ({ + let pos = view_cursor.position().unwrap_or_default(); + Rectangle { + x: pos.x as i32, + y: pos.y as i32, + width: 1, + height: 1, + } + }, + match (state.horizontal_direction, state.vertical_direction) { + (Direction::Positive, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight, + (Direction::Positive, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopRight, + (Direction::Negative, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomLeft, + (Direction::Negative, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopLeft, + }) + }); + + let menu_node = + popup_menu.layout(renderer, iced::Limits::NONE.min_width(1.).min_height(1.)); + let popup_size = menu_node.size(); + let positioner = SctkPositioner { + size: Some(( + popup_size.width.ceil() as u32 + 2, + popup_size.height.ceil() as u32 + 2, + )), + anchor_rect, + anchor: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::None, + gravity: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight, + reactive: true, + ..Default::default() + }; + let parent = self.window_id; + shell.publish((self.on_surface_action.as_ref().unwrap())( + crate::surface::action::simple_popup( + move || SctkPopupSettings { + parent, + id, + positioner: positioner.clone(), + parent_size: None, + grab: true, + close_with_children: false, + input_zone: None, + }, + Some(move || { + crate::Element::from( + crate::widget::container(popup_menu.clone()).center(Length::Fill), + ) + .map(crate::action::app) + }), + ), + )); + } + } + + pub fn on_surface_action( + mut self, + handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, + ) -> Self { + self.on_surface_action = Some(Arc::new(handler)); + self + } +} + +impl Widget + for ContextMenu<'_, Message> { fn tag(&self) -> tree::Tag { tree::Tag::of::() @@ -58,6 +227,7 @@ impl<'a, Message: Clone> Widget tree::State::new(LocalState { context_cursor: Point::default(), fingers_pressed: Default::default(), + menu_bar_state: Default::default(), }) } @@ -69,7 +239,6 @@ impl<'a, Message: Clone> Widget // Assign the context menu's elements as this widget's children. if let Some(ref context_menu) = self.context_menu { let mut tree = Tree::empty(); - tree.state = tree::State::new(MenuBarState::default()); tree.children = context_menu .iter() .map(|root| { @@ -77,7 +246,7 @@ impl<'a, Message: Clone> Widget let flat = root .flattern() .iter() - .map(|mt| Tree::new(mt.item.as_widget())) + .map(|mt| Tree::new(mt.item.clone())) .collect(); tree.children = flat; tree @@ -91,7 +260,11 @@ impl<'a, Message: Clone> Widget } fn diff(&mut self, tree: &mut Tree) { - tree.children[0].diff(self.content.as_widget_mut()); + tree.diff_children(std::slice::from_mut(&mut self.content)); + let state = tree.state.downcast_mut::(); + state.menu_bar_state.inner.with_data_mut(|inner| { + menu_roots_diff(self.context_menu.as_mut().unwrap(), &mut inner.tree); + }); // if let Some(ref mut context_menus) = self.context_menu { // for (menu, tree) in context_menus @@ -108,13 +281,13 @@ impl<'a, Message: Clone> Widget } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &crate::Renderer, limits: &iced_core::layout::Limits, ) -> iced_core::layout::Node { self.content - .as_widget() + .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) } @@ -140,63 +313,153 @@ impl<'a, Message: Clone> Widget } fn operate( - &self, + &mut self, tree: &mut Tree, layout: iced_core::Layout<'_>, renderer: &crate::Renderer, - operation: &mut dyn iced_core::widget::Operation< - iced_core::widget::OperationOutputWrapper, - >, + operation: &mut dyn iced_core::widget::Operation<()>, ) { self.content - .as_widget() + .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); } - fn on_event( + #[allow(clippy::too_many_lines)] + fn update( &mut self, tree: &mut Tree, - event: iced::Event, + event: &iced::Event, layout: iced_core::Layout<'_>, cursor: iced_core::mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn iced_core::Clipboard, shell: &mut iced_core::Shell<'_, Message>, viewport: &iced::Rectangle, - ) -> iced_core::event::Status { + ) { let state = tree.state.downcast_mut::(); let bounds = layout.bounds(); - if cursor.is_over(bounds) { + // XXX this should reset the state if there are no other copies of the state, which implies no dropdown menus open. + let reset = self.window_id != window::Id::NONE + && state + .menu_bar_state + .inner + .with_data(|d| !d.open && !d.active_root.is_empty()); + + let open = state.menu_bar_state.inner.with_data_mut(|state| { + if reset + && let Some(popup_id) = state.popup_id.get(&self.window_id).copied() + && let Some(handler) = self.on_surface_action.as_ref() + { + shell.publish((handler)(crate::surface::Action::DestroyPopup(popup_id))); + state.reset(); + } + state.open + }); + let mut was_open = false; + if matches!(event, + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Escape), + .. + }) + | Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Right | mouse::Button::Left, + )) + | Event::Touch(touch::Event::FingerPressed { .. }) + if open ) + { + state.menu_bar_state.inner.with_data_mut(|state| { + was_open = true; + state.menu_states.clear(); + state.active_root.clear(); + state.open = false; + + #[cfg(all( + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) + && let Some(id) = state.popup_id.remove(&self.window_id) + { + { + let surface_action = self.on_surface_action.as_ref().unwrap(); + shell.publish(surface_action(crate::surface::action::destroy_popup(id))); + } + state.view_cursor = cursor; + } + }); + } + + if !was_open && cursor.is_over(bounds) { let fingers_pressed = state.fingers_pressed.len(); match event { Event::Touch(touch::Event::FingerPressed { id, .. }) => { - state.fingers_pressed.insert(id); + state.fingers_pressed.insert(*id); } Event::Touch(touch::Event::FingerLifted { id, .. }) => { - state.fingers_pressed.remove(&id); + state.fingers_pressed.remove(id); } _ => (), } // Present a context menu on a right click event. - if self.context_menu.is_some() - && (right_button_released(&event) || (touch_lifted(&event) && fingers_pressed == 2)) + if !was_open + && self.context_menu.is_some() + && (right_button_released(event) || (touch_lifted(event) && fingers_pressed == 2)) { state.context_cursor = cursor.position().unwrap_or_default(); + let state = tree.state.downcast_mut::(); + state.menu_bar_state.inner.with_data_mut(|state| { + state.open = true; + state.view_cursor = cursor; + }); + #[cfg(all( + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) { + self.create_popup(layout, cursor, renderer, shell, viewport, state); + } - let menu_state = tree.children[1].state.downcast_mut::(); - menu_state.open = true; - menu_state.view_cursor = cursor; + shell.capture_event(); + return; + } else if !was_open && right_button_released(event) + || (touch_lifted(event)) + || left_button_released(event) + { + state.menu_bar_state.inner.with_data_mut(|state| { + was_open = true; + state.menu_states.clear(); + state.active_root.clear(); + state.open = false; - return event::Status::Captured; + #[cfg(all( + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) + && let Some(id) = state.popup_id.remove(&self.window_id) + { + { + let surface_action = self.on_surface_action.as_ref().unwrap(); + shell + .publish(surface_action(crate::surface::action::destroy_popup(id))); + } + state.view_cursor = cursor; + } + }); } } - - self.content.as_widget_mut().on_event( + self.content.as_widget_mut().update( &mut tree.children[0], event, layout, @@ -205,7 +468,7 @@ impl<'a, Message: Clone> Widget clipboard, shell, viewport, - ) + ); } fn overlay<'b>( @@ -213,25 +476,37 @@ impl<'a, Message: Clone> Widget tree: &'b mut Tree, layout: iced_core::Layout<'_>, _renderer: &crate::Renderer, + _viewport: &iced::Rectangle, + translation: Vector, ) -> Option> { + #[cfg(all( + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) + && self.window_id != window::Id::NONE + && self.on_surface_action.is_some() + { + return None; + } + let state = tree.state.downcast_ref::(); - let Some(context_menu) = self.context_menu.as_mut() else { - return None; - }; + let context_menu = self.context_menu.as_mut()?; - if !tree.children[1].state.downcast_ref::().open { + if !state.menu_bar_state.inner.with_data(|state| state.open) { return None; } let mut bounds = layout.bounds(); bounds.x = state.context_cursor.x; bounds.y = state.context_cursor.y; - Some( crate::widget::menu::Menu { - tree: &mut tree.children[1], - menu_roots: context_menu, + tree: state.menu_bar_state.clone(), + menu_roots: std::borrow::Cow::Owned(context_menu.clone()), bounds_expand: 16, menu_overlays_parent: true, close_condition: CloseCondition { @@ -246,14 +521,31 @@ impl<'a, Message: Clone> Widget cross_offset: 0, root_bounds_list: vec![bounds], path_highlight: Some(PathHighlight::MenuActive), - style: &crate::theme::menu_bar::MenuBarStyle::Default, + style: std::borrow::Cow::Borrowed(&crate::theme::menu_bar::MenuBarStyle::Default), + position: Point::new(translation.x, translation.y), + is_overlay: true, + window_id: window::Id::NONE, + depth: 0, + on_surface_action: None, } .overlay(), ) } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: iced_core::Layout<'_>, + state: &Tree, + p: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + let c_state = &state.children[0]; + self.content.as_widget().a11y_nodes(layout, c_state, p) + } } -impl<'a, Message: Clone + 'a> From> for crate::Element<'a, Message> { +impl<'a, Message: Clone + 'static> From> for crate::Element<'a, Message> { fn from(widget: ContextMenu<'a, Message>) -> Self { Self::new(widget) } @@ -266,6 +558,13 @@ fn right_button_released(event: &Event) -> bool { ) } +fn left_button_released(event: &Event) -> bool { + matches!( + event, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left,)) + ) +} + fn touch_lifted(event: &Event) -> bool { matches!(event, Event::Touch(touch::Event::FingerLifted { .. })) } @@ -273,4 +572,5 @@ fn touch_lifted(event: &Event) -> bool { pub struct LocalState { context_cursor: Point, fingers_pressed: HashSet, + menu_bar_state: MenuBarState, } diff --git a/src/widget/dialog.rs b/src/widget/dialog.rs index 7e583d2b..7d084626 100644 --- a/src/widget/dialog.rs +++ b/src/widget/dialog.rs @@ -1,33 +1,56 @@ -use crate::{iced::Length, style, theme, widget, Element}; +use crate::{ + Element, + iced::{Length, Pixels}, + style, theme, widget, +}; use std::borrow::Cow; -pub fn dialog<'a, Message>(title: impl Into>) -> Dialog<'a, Message> { - Dialog::new(title) +pub fn dialog<'a, Message>() -> Dialog<'a, Message> { + Dialog::new() } pub struct Dialog<'a, Message> { - title: Cow<'a, str>, + title: Option>, icon: Option>, body: Option>, controls: Vec>, primary_action: Option>, secondary_action: Option>, tertiary_action: Option>, + width: Option, + height: Option, + max_width: Option, + max_height: Option, +} + +impl Default for Dialog<'_, Message> { + fn default() -> Self { + Self::new() + } } impl<'a, Message> Dialog<'a, Message> { - pub fn new(title: impl Into>) -> Self { + pub fn new() -> Self { Self { - title: title.into(), + title: None, icon: None, body: None, controls: Vec::new(), primary_action: None, secondary_action: None, tertiary_action: None, + width: None, + height: None, + max_width: None, + max_height: None, } } + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + pub fn icon(mut self, icon: impl Into>) -> Self { self.icon = Some(icon.into()); self @@ -57,6 +80,26 @@ impl<'a, Message> Dialog<'a, Message> { self.tertiary_action = Some(button.into()); self } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } + + pub fn height(mut self, height: impl Into) -> Self { + self.height = Some(height.into()); + self + } + + pub fn max_height(mut self, max_height: impl Into) -> Self { + self.max_height = Some(max_height.into()); + self + } + + pub fn max_width(mut self, max_width: impl Into) -> Self { + self.max_width = Some(max_width.into()); + self + } } impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { @@ -70,14 +113,30 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes } = theme::THEME.lock().unwrap().cosmic().spacing; let mut content_col = widget::column::with_capacity(3 + dialog.controls.len() * 2); - content_col = content_col.push(widget::text::title3(dialog.title)); + + let mut should_space = false; + + if let Some(title) = dialog.title { + content_col = content_col.push(widget::text::title3(title)); + should_space = true; + } if let Some(body) = dialog.body { - content_col = content_col.push(widget::vertical_space(Length::Fixed(space_xxs.into()))); - content_col = content_col.push(widget::text::body(body)); + if should_space { + content_col = content_col + .push(widget::space::vertical().height(Length::Fixed(space_xxs.into()))); + } + content_col = content_col.push( + widget::container(widget::scrollable(widget::text::body(body))).max_height(300.), + ); + should_space = true; } for control in dialog.controls { - content_col = content_col.push(widget::vertical_space(Length::Fixed(space_s.into()))); + if should_space { + content_col = content_col + .push(widget::space::vertical().height(Length::Fixed(space_s.into()))); + } content_col = content_col.push(control); + should_space = true; } let mut content_row = widget::row::with_capacity(2).spacing(space_s); @@ -90,7 +149,7 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes if let Some(button) = dialog.tertiary_action { button_row = button_row.push(button); } - button_row = button_row.push(widget::horizontal_space(Length::Fill)); + button_row = button_row.push(widget::space::horizontal()); if let Some(button) = dialog.secondary_action { button_row = button_row.push(button); } @@ -98,14 +157,25 @@ impl<'a, Message: Clone + 'static> From> for Element<'a, Mes button_row = button_row.push(button); } - Element::from( - widget::container( - widget::column::with_children(vec![content_row.into(), button_row.into()]) - .spacing(space_l), - ) - .style(style::Container::Dialog) - .padding(space_m) - .width(Length::Fixed(570.0)), + let mut container = widget::container( + widget::column::with_children([content_row.into(), button_row.into()]).spacing(space_l), ) + .class(style::Container::Dialog) + .padding(space_m) + .width(dialog.width.unwrap_or(Length::Fixed(570.0))); + + if let Some(height) = dialog.height { + container = container.height(height); + } + + if let Some(max_width) = dialog.max_width { + container = container.max_width(max_width); + } + + if let Some(max_height) = dialog.max_height { + container = container.max_height(max_height); + } + + Element::from(container) } } diff --git a/src/widget/dnd_destination.rs b/src/widget/dnd_destination.rs index 5cb32711..10bf7a8b 100644 --- a/src/widget/dnd_destination.rs +++ b/src/widget/dnd_destination.rs @@ -3,23 +3,26 @@ use std::{ sync::atomic::{AtomicU64, Ordering}, }; +use iced::Vector; + use crate::{ - iced::{ - clipboard::{ - dnd::{self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent}, - mime::AllowedMimeTypes, - }, - event, - id::Internal, - mouse, overlay, Event, Length, Rectangle, - }, - iced_core::{ - self, layout, - widget::{tree, Tree}, - Clipboard, Shell, - }, - widget::{Id, Widget}, Element, + widget::{Id, Widget}, +}; + +use iced::{ + Event, Length, Rectangle, + clipboard::{ + dnd::{self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent}, + mime::AllowedMimeTypes, + }, + event, + id::Internal, + mouse, overlay, +}; +use iced_core::{ + self, Clipboard, Shell, layout, + widget::{Tree, tree}, }; pub fn dnd_destination<'a, Message: 'static>( @@ -29,14 +32,17 @@ pub fn dnd_destination<'a, Message: 'static>( DndDestination::new(child, mimes) } -pub fn dnd_destination_for_data( - child: impl Into>, +pub fn dnd_destination_for_data<'a, T: AllowedMimeTypes, Message: 'static>( + child: impl Into>, on_finish: impl Fn(Option, DndAction) -> Message + 'static, -) -> DndDestination<'static, Message> { +) -> DndDestination<'a, Message> { DndDestination::for_data(child, on_finish) } static DRAG_ID_COUNTER: AtomicU64 = AtomicU64::new(0); +const DND_DEST_LOG_TARGET: &str = "libcosmic::widget::dnd_destination"; +#[cfg(feature = "xdg-portal")] +pub const FILE_TRANSFER_MIME: &str = "application/vnd.portal.filetransfer"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DragId(pub u128); @@ -70,9 +76,17 @@ pub struct DndDestination<'a, Message> { on_action_selected: Option Message>>, on_data_received: Option) -> Message>>, on_finish: Option, DndAction, f64, f64) -> Message>>, + #[cfg(feature = "xdg-portal")] + on_file_transfer: Option Message>>, } impl<'a, Message: 'static> DndDestination<'a, Message> { + fn mime_matches(&self, offered: &[String]) -> bool { + self.mime_types.is_empty() + || offered + .iter() + .any(|mime| self.mime_types.iter().any(|allowed| allowed == mime)) + } pub fn new(child: impl Into>, mimes: Vec>) -> Self { Self { id: Id::unique(), @@ -90,6 +104,8 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { on_action_selected: None, on_data_received: None, on_finish: None, + #[cfg(feature = "xdg-portal")] + on_file_transfer: None, } } @@ -115,6 +131,8 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { on_finish: Some(Box::new(move |mime, data, action, _, _| { on_finish(T::try_from((data, mime)).ok(), action) })), + #[cfg(feature = "xdg-portal")] + on_file_transfer: None, } } @@ -150,6 +168,8 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { on_action_selected: None, on_data_received: None, on_finish: None, + #[cfg(feature = "xdg-portal")] + on_file_transfer: None, } } @@ -228,6 +248,20 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { self } + /// Add a message that will be emitted instead of [`on_data_received`](Self::on_data_received) if the dropped files + /// are offered through the xdg share portal. You can then use [`crate::command::file_transfer_receive`] + /// with the key to receive the files. + #[cfg(feature = "xdg-portal")] + #[must_use] + pub fn on_file_transfer(mut self, f: impl Fn(String) -> Message + 'static) -> Self { + match self.mime_types.iter().position(|v| v == "text/uri-list") { + Some(i) => self.mime_types.insert(i, Cow::Borrowed(FILE_TRANSFER_MIME)), + None => self.mime_types.push(Cow::Borrowed(FILE_TRANSFER_MIME)), + } + self.on_file_transfer = Some(Box::new(f)); + self + } + /// Returns the drag id of the destination. /// /// # Panics @@ -239,10 +273,15 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { Internal::Set(_) => panic!("Invalid Id assigned to dnd destination."), })) } + + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } } -impl<'a, Message: 'static> Widget - for DndDestination<'a, Message> +impl Widget + for DndDestination<'_, Message> { fn children(&self) -> Vec { vec![Tree::new(&self.container)] @@ -253,7 +292,7 @@ impl<'a, Message: 'static> Widget } fn diff(&mut self, tree: &mut Tree) { - tree.children[0].diff(self.container.as_widget_mut()); + tree.diff_children(std::slice::from_mut(&mut self.container)); } fn state(&self) -> iced_core::widget::tree::State { @@ -265,45 +304,43 @@ impl<'a, Message: 'static> Widget } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &crate::Renderer, limits: &layout::Limits, ) -> layout::Node { self.container - .as_widget() + .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) } fn operate( - &self, + &mut self, tree: &mut Tree, layout: layout::Layout<'_>, renderer: &crate::Renderer, - operation: &mut dyn iced_core::widget::Operation< - iced_core::widget::OperationOutputWrapper, - >, + operation: &mut dyn iced_core::widget::Operation<()>, ) { self.container - .as_widget() + .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); } #[allow(clippy::too_many_lines)] - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: layout::Layout<'_>, cursor: mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { - let s = self.container.as_widget_mut().on_event( + ) { + self.container.as_widget_mut().update( &mut tree.children[0], - event.clone(), + event, layout, cursor, renderer, @@ -311,25 +348,43 @@ impl<'a, Message: 'static> Widget shell, viewport, ); - if matches!(s, event::Status::Captured) { - return event::Status::Captured; + if shell.is_event_captured() { + return; } let state = tree.state.downcast_mut::>(); let my_id = self.get_drag_id(); + log::trace!( + target: DND_DEST_LOG_TARGET, + "dnd_destination id={:?}: event {:?}", + self.drag_id.unwrap_or_default(), + event + ); match event { Event::Dnd(DndEvent::Offer( id, OfferEvent::Enter { x, y, mime_types, .. }, - )) if id == Some(my_id) => { + )) if *id == Some(my_id) => { + if !self.mime_matches(&mime_types) { + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer enter id={my_id:?} ignored (mimes={mime_types:?} not in {:?})", + self.mime_types + ); + return; + } + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer enter id={my_id:?} coords=({x},{y}) mimes={mime_types:?}" + ); if let Some(msg) = state.on_enter( - x, - y, - mime_types, + *x, + *y, + mime_types.clone(), self.on_enter.as_ref().map(std::convert::AsRef::as_ref), (), ) { @@ -337,13 +392,13 @@ impl<'a, Message: 'static> Widget } if self.forward_drag_as_cursor { #[allow(clippy::cast_possible_truncation)] - let drag_cursor = mouse::Cursor::Available((x as f32, y as f32).into()); + let drag_cursor = mouse::Cursor::Available((*x as f32, *y as f32).into()); let event = Event::Mouse(mouse::Event::CursorMoved { position: drag_cursor.position().unwrap(), }); - self.container.as_widget_mut().on_event( + self.container.as_widget_mut().update( &mut tree.children[0], - event, + &event, layout, drag_cursor, renderer, @@ -352,17 +407,27 @@ impl<'a, Message: 'static> Widget viewport, ); } - return event::Status::Captured; + shell.capture_event(); + return; } - Event::Dnd(DndEvent::Offer(id, OfferEvent::Leave)) if id == Some(my_id) => { - state.on_leave(self.on_leave.as_ref().map(std::convert::AsRef::as_ref)); + Event::Dnd(DndEvent::Offer(_, OfferEvent::Leave)) => { + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer leave id={:?}", + my_id + ); + if let Some(msg) = + state.on_leave(self.on_leave.as_ref().map(std::convert::AsRef::as_ref)) + { + shell.publish(msg); + } if self.forward_drag_as_cursor { let drag_cursor = mouse::Cursor::Unavailable; let event = Event::Mouse(mouse::Event::CursorLeft); - self.container.as_widget_mut().on_event( + self.container.as_widget_mut().update( &mut tree.children[0], - event, + &event, layout, drag_cursor, renderer, @@ -371,12 +436,16 @@ impl<'a, Message: 'static> Widget viewport, ); } - return event::Status::Captured; + return; } - Event::Dnd(DndEvent::Offer(id, OfferEvent::Motion { x, y })) if id == Some(my_id) => { + Event::Dnd(DndEvent::Offer(id, OfferEvent::Motion { x, y })) if *id == Some(my_id) => { + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer motion id={my_id:?} coords=({x},{y})" + ); if let Some(msg) = state.on_motion( - x, - y, + *x, + *y, self.on_motion.as_ref().map(std::convert::AsRef::as_ref), self.on_enter.as_ref().map(std::convert::AsRef::as_ref), (), @@ -386,13 +455,13 @@ impl<'a, Message: 'static> Widget if self.forward_drag_as_cursor { #[allow(clippy::cast_possible_truncation)] - let drag_cursor = mouse::Cursor::Available((x as f32, y as f32).into()); + let drag_cursor = mouse::Cursor::Available((*x as f32, *y as f32).into()); let event = Event::Mouse(mouse::Event::CursorMoved { position: drag_cursor.position().unwrap(), }); - self.container.as_widget_mut().on_event( + self.container.as_widget_mut().update( &mut tree.children[0], - event, + &event, layout, drag_cursor, renderer, @@ -401,56 +470,95 @@ impl<'a, Message: 'static> Widget viewport, ); } - return event::Status::Captured; + shell.capture_event(); + return; } - Event::Dnd(DndEvent::Offer(id, OfferEvent::LeaveDestination)) if id == Some(my_id) => { + Event::Dnd(DndEvent::Offer(_, OfferEvent::LeaveDestination)) => { + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer leave-destination id={:?}", + my_id + ); if let Some(msg) = state.on_leave(self.on_leave.as_ref().map(std::convert::AsRef::as_ref)) { shell.publish(msg); } - return event::Status::Captured; + return; } - Event::Dnd(DndEvent::Offer(id, OfferEvent::Drop)) if id == Some(my_id) => { + Event::Dnd(DndEvent::Offer(id, OfferEvent::Drop)) if *id == Some(my_id) => { + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer drop id={my_id:?}" + ); if let Some(msg) = state.on_drop(self.on_drop.as_ref().map(std::convert::AsRef::as_ref)) { shell.publish(msg); } - return event::Status::Captured; + shell.capture_event(); + return; } Event::Dnd(DndEvent::Offer(id, OfferEvent::SelectedAction(action))) - if id == Some(my_id) => + if *id == Some(my_id) => { + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer selected-action id={my_id:?} action={action:?}" + ); if let Some(msg) = state.on_action_selected( - action, + *action, self.on_action_selected .as_ref() .map(std::convert::AsRef::as_ref), ) { shell.publish(msg); } - return event::Status::Captured; + shell.capture_event(); + return; } Event::Dnd(DndEvent::Offer(id, OfferEvent::Data { data, mime_type })) - if id == Some(my_id) => + if *id == Some(my_id) => { + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer data id={my_id:?} mime={mime_type:?} bytes={}", + data.len() + ); + + #[cfg(feature = "xdg-portal")] + if mime_type == FILE_TRANSFER_MIME + && let Some(f) = self.on_file_transfer.as_ref() + && let Ok(s) = String::from_utf8(data[..data.len() - 1].to_vec()) + { + shell.publish(f(s)); + shell.capture_event(); + return; + } + if let (Some(msg), ret) = state.on_data_received( - mime_type, - data, + mime_type.clone(), + data.clone(), self.on_data_received .as_ref() .map(std::convert::AsRef::as_ref), self.on_finish.as_ref().map(std::convert::AsRef::as_ref), ) { shell.publish(msg); - return ret; + if ret == event::Status::Captured { + log::trace!( + target: DND_DEST_LOG_TARGET, + "offer data id={my_id:?} captured" + ); + shell.capture_event(); + } + return; } - return event::Status::Captured; + shell.capture_event(); + return; } _ => {} } - event::Status::Ignored } fn mouse_interaction( @@ -494,12 +602,18 @@ impl<'a, Message: 'static> Widget fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: layout::Layout<'_>, + layout: layout::Layout<'b>, renderer: &crate::Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - self.container - .as_widget_mut() - .overlay(&mut tree.children[0], layout, renderer) + self.container.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) } fn drag_destinations( @@ -511,6 +625,16 @@ impl<'a, Message: 'static> Widget ) { let bounds = layout.bounds(); let my_id = self.get_drag_id(); + log::trace!( + target: DND_DEST_LOG_TARGET, + "register destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}", + my_id, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + self.mime_types + ); let my_dest = DndDestinationRectangle { id: my_id, rectangle: dnd::Rectangle { @@ -540,6 +664,18 @@ impl<'a, Message: 'static> Widget fn set_id(&mut self, id: Id) { self.id = id; } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: iced_core::Layout<'_>, + state: &Tree, + p: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + let c_state = &state.children[0]; + self.container.as_widget().a11y_nodes(layout, c_state, p) + } } #[derive(Default)] @@ -674,3 +810,71 @@ impl<'a, Message: 'static> From> for Element<'a, Mes Element::new(wrapper) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Copy, Debug, PartialEq)] + enum TestMsg { + Data, + Finished, + } + + #[test] + fn data_before_drop_invokes_data_handler_only() { + let mut state: State<()> = State::new(); + assert!(state.drag_offer.is_none()); + state.on_enter::( + 4.0, + 2.0, + vec!["text/plain".into()], + Option:: TestMsg>::None, + (), + ); + let (message, status) = state.on_data_received( + "text/plain".into(), + vec![1], + Some(|mime, data| { + assert_eq!(mime, "text/plain"); + assert_eq!(data, vec![1]); + TestMsg::Data + }), + Option:: TestMsg>::None, + ); + assert!(matches!(message, Some(TestMsg::Data))); + assert_eq!(status, event::Status::Captured); + assert!(state.drag_offer.is_some()); + } + + #[test] + fn finish_only_emits_after_drop() { + let mut state: State<()> = State::new(); + state.on_enter::( + 5.0, + -1.0, + vec![], + Option:: TestMsg>::None, + (), + ); + state.on_action_selected::(DndAction::Move, Option:: TestMsg>::None); + state.on_drop::(Option:: TestMsg>::None); + + let (message, status) = state.on_data_received( + "application/x-test".into(), + vec![7], + Option:: TestMsg>::None, + Some(|mime, data, action, x, y| { + assert_eq!(mime, "application/x-test"); + assert_eq!(data, vec![7]); + assert_eq!(action, DndAction::Move); + assert_eq!(x, 5.0); + assert_eq!(y, -1.0); + TestMsg::Finished + }), + ); + assert!(matches!(message, Some(TestMsg::Finished))); + assert_eq!(status, event::Status::Captured); + assert!(state.drag_offer.is_none()); + } +} diff --git a/src/widget/dnd_source.rs b/src/widget/dnd_source.rs index 03de37e2..980723e3 100644 --- a/src/widget/dnd_source.rs +++ b/src/widget/dnd_source.rs @@ -1,69 +1,77 @@ use std::any::Any; +use iced_core::{widget::Operation, window}; + use crate::{ - iced::{ - clipboard::dnd::{DndAction, DndEvent, SourceEvent}, - event, mouse, overlay, Event, Length, Point, Rectangle, - }, - iced_core::{ - self, layout, renderer, - widget::{tree, Tree}, - Clipboard, Shell, - }, - iced_style, - widget::{container, Id, Widget}, Element, + widget::{Id, Widget, container}, +}; +use iced::{ + Event, Length, Point, Rectangle, Vector, + clipboard::dnd::{DndAction, DndEvent, SourceEvent}, + event, mouse, overlay, +}; +use iced_core::{ + self, Clipboard, Shell, layout, renderer, + widget::{Tree, tree}, }; pub fn dnd_source< 'a, - Message: 'static, - AppMessage: 'static, + Message: Clone + 'static, D: iced::clipboard::mime::AsMimeTypes + Send + 'static, >( child: impl Into>, -) -> DndSource<'a, Message, AppMessage, D> { +) -> DndSource<'a, Message, D> { DndSource::new(child) } -pub struct DndSource<'a, Message, AppMessage, D> { +pub struct DndSource<'a, Message, D> { id: Id, action: DndAction, container: Element<'a, Message>, + window: Option, drag_content: Option D>>, - drag_icon: Option (Element<'static, AppMessage>, tree::State)>>, + drag_icon: Option (Element<'static, ()>, tree::State, Vector)>>, + on_start: Option, + on_cancelled: Option, + on_finish: Option, drag_threshold: f32, - _phantom: std::marker::PhantomData, } impl< - 'a, - Message: 'static, - AppMessage: 'static, - D: iced::clipboard::mime::AsMimeTypes + std::marker::Send + 'static, - > DndSource<'a, Message, AppMessage, D> + 'a, + Message: Clone + 'static, + D: iced::clipboard::mime::AsMimeTypes + std::marker::Send + 'static, +> DndSource<'a, Message, D> { pub fn new(child: impl Into>) -> Self { Self { id: Id::unique(), + window: None, action: DndAction::Copy | DndAction::Move, container: container(child).into(), drag_content: None, drag_icon: None, drag_threshold: 8.0, - _phantom: std::marker::PhantomData, + on_start: None, + on_cancelled: None, + on_finish: None, } } pub fn with_id(child: impl Into>, id: Id) -> Self { Self { id, + window: None, action: DndAction::Copy | DndAction::Move, container: container(child).into(), drag_content: None, drag_icon: None, drag_threshold: 8.0, - _phantom: std::marker::PhantomData, + on_start: None, + on_cancelled: None, + on_finish: None, } } @@ -82,7 +90,7 @@ impl< #[must_use] pub fn drag_icon( mut self, - f: impl Fn() -> (Element<'static, AppMessage>, tree::State) + 'static, + f: impl Fn(Vector) -> (Element<'static, ()>, tree::State, Vector) + 'static, ) -> Self { self.drag_icon = Some(Box::new(f)); self @@ -94,36 +102,62 @@ impl< self } - pub fn start_dnd(&self, clipboard: &mut dyn Clipboard, bounds: Rectangle) { + pub fn start_dnd(&self, clipboard: &mut dyn Clipboard, bounds: Rectangle, offset: Vector) { let Some(content) = self.drag_content.as_ref().map(|f| f()) else { return; }; + iced_core::clipboard::start_dnd( clipboard, false, - Some(iced_core::clipboard::DndSource::Widget(self.id.clone())), + if let Some(window) = self.window.as_ref() { + Some(iced_core::clipboard::DndSource::Surface(*window)) + } else { + Some(iced_core::clipboard::DndSource::Widget(self.id.clone())) + }, self.drag_icon.as_ref().map(|f| { - let (icon, state) = f(); - ( + let (icon, state, offset) = f(offset); + iced_core::clipboard::IconSurface::new( container(icon) .width(Length::Fixed(bounds.width)) .height(Length::Fixed(bounds.height)) .into(), state, + offset, ) }), Box::new(content), self.action, ); } + + #[must_use] + pub fn on_start(mut self, on_start: Option) -> Self { + self.on_start = on_start; + self + } + + #[must_use] + pub fn on_cancel(mut self, on_cancelled: Option) -> Self { + self.on_cancelled = on_cancelled; + self + } + + #[must_use] + pub fn on_finish(mut self, on_finish: Option) -> Self { + self.on_finish = on_finish; + self + } + + #[must_use] + pub fn window(mut self, window: window::Id) -> Self { + self.window = Some(window); + self + } } -impl< - 'a, - Message: 'static, - AppMessage: 'static, - D: iced::clipboard::mime::AsMimeTypes + std::marker::Send + 'static, - > Widget for DndSource<'a, Message, AppMessage, D> +impl + Widget for DndSource<'_, Message, D> { fn children(&self) -> Vec { vec![Tree::new(&self.container)] @@ -134,7 +168,7 @@ impl< } fn diff(&mut self, tree: &mut Tree) { - tree.children[0].diff(self.container.as_widget_mut()); + tree.diff_children(std::slice::from_mut(&mut self.container)); } fn state(&self) -> iced_core::widget::tree::State { @@ -146,7 +180,7 @@ impl< } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &crate::Renderer, limits: &layout::Limits, @@ -154,43 +188,44 @@ impl< let state = tree.state.downcast_mut::(); let node = self .container - .as_widget() + .as_widget_mut() .layout(&mut tree.children[0], renderer, limits); state.cached_bounds = node.bounds(); node } fn operate( - &self, + &mut self, tree: &mut Tree, layout: layout::Layout<'_>, renderer: &crate::Renderer, - operation: &mut dyn iced_core::widget::Operation< - iced_core::widget::OperationOutputWrapper, - >, + operation: &mut dyn Operation, ) { - operation.custom((&mut tree.state) as &mut dyn Any, Some(&self.id)); - operation.container(Some(&self.id), layout.bounds(), &mut |operation| { - self.container - .as_widget() - .operate(&mut tree.children[0], layout, renderer, operation) - }); + operation.custom( + Some(&self.id), + layout.bounds(), + (&mut tree.state) as &mut dyn Any, + ); + + self.container + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: layout::Layout<'_>, cursor: mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { - let ret = self.container.as_widget_mut().on_event( + ) { + self.container.as_widget_mut().update( &mut tree.children[0], - event.clone(), + event, layout, cursor, renderer, @@ -205,60 +240,69 @@ impl< Event::Mouse(mouse_event) => match mouse_event { mouse::Event::ButtonPressed(mouse::Button::Left) => { if let Some(position) = cursor.position() { - if !state.hovered { - return ret; + if !cursor.is_over(layout.bounds()) { + return; } state.left_pressed_position = Some(position); - // dbg!(&state, &self.id); - return event::Status::Captured; + shell.capture_event(); } } mouse::Event::ButtonReleased(mouse::Button::Left) if state.left_pressed_position.is_some() => { state.left_pressed_position = None; - return event::Status::Captured; + shell.capture_event(); } mouse::Event::CursorMoved { .. } => { if let Some(position) = cursor.position() { - if state.hovered { - // We ignore motion if we do not possess drag content by now. - if self.drag_content.is_none() { - state.left_pressed_position = None; - return ret; - } - if let Some(left_pressed_position) = state.left_pressed_position { - // dbg!(&state); - if position.distance(left_pressed_position) > self.drag_threshold { - self.start_dnd(clipboard, state.cached_bounds); - state.is_dragging = true; - state.left_pressed_position = None; - } - } - if !cursor.is_over(layout.bounds()) { - state.hovered = false; - - return ret; - } - } else if cursor.is_over(layout.bounds()) { - state.hovered = true; + // We ignore motion if we do not possess drag content by now. + if self.drag_content.is_none() { + state.left_pressed_position = None; + return; } - return event::Status::Captured; + if let Some(left_pressed_position) = state.left_pressed_position + && position.distance(left_pressed_position) > self.drag_threshold + { + if let Some(on_start) = self.on_start.as_ref() { + shell.publish(on_start.clone()); + } + let offset = Vector::new( + left_pressed_position.x - layout.bounds().x, + left_pressed_position.y - layout.bounds().y, + ); + self.start_dnd(clipboard, state.cached_bounds, offset); + state.is_dragging = true; + state.left_pressed_position = None; + } + if !cursor.is_over(layout.bounds()) { + return; + } + shell.capture_event(); } } - _ => return ret, + _ => (), }, - Event::Dnd(DndEvent::Source(SourceEvent::Cancelled | SourceEvent::Finished)) => { + Event::Dnd(DndEvent::Source(SourceEvent::Cancelled)) => { if state.is_dragging { + if let Some(m) = self.on_cancelled.as_ref() { + shell.publish(m.clone()); + } state.is_dragging = false; - return event::Status::Captured; + shell.capture_event(); } - return ret; } - _ => return ret, + Event::Dnd(DndEvent::Source(SourceEvent::Finished)) => { + if state.is_dragging { + if let Some(m) = self.on_finish.as_ref() { + shell.publish(m.clone()); + } + state.is_dragging = false; + shell.capture_event(); + } + } + _ => (), } - ret } fn mouse_interaction( @@ -306,12 +350,18 @@ impl< fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: layout::Layout<'_>, + layout: layout::Layout<'b>, renderer: &crate::Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - self.container - .as_widget_mut() - .overlay(&mut tree.children[0], layout, renderer) + self.container.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) } fn drag_destinations( @@ -319,7 +369,7 @@ impl< state: &Tree, layout: layout::Layout<'_>, renderer: &crate::Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { self.container.as_widget().drag_destinations( &state.children[0], @@ -336,16 +386,27 @@ impl< fn set_id(&mut self, id: Id) { self.id = id; } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: iced_core::Layout<'_>, + state: &Tree, + p: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + let c_state = &state.children[0]; + self.container.as_widget().a11y_nodes(layout, c_state, p) + } } impl< - 'a, - Message: 'static, - AppMessage: 'static, - D: iced::clipboard::mime::AsMimeTypes + std::marker::Send + 'static, - > From> for Element<'a, Message> + 'a, + Message: Clone + 'static, + D: iced::clipboard::mime::AsMimeTypes + std::marker::Send + 'static, +> From> for Element<'a, Message> { - fn from(e: DndSource<'a, Message, AppMessage, D>) -> Element<'a, Message> { + fn from(e: DndSource<'a, Message, D>) -> Element<'a, Message> { Element::new(e) } } @@ -353,7 +414,6 @@ impl< /// Local state of the [`MouseListener`]. #[derive(Debug, Default)] struct State { - hovered: bool, left_pressed_position: Option, is_dragging: bool, cached_bounds: Rectangle, diff --git a/src/widget/dropdown/menu/appearance.rs b/src/widget/dropdown/menu/appearance.rs index 64c524c2..d1bed21c 100644 --- a/src/widget/dropdown/menu/appearance.rs +++ b/src/widget/dropdown/menu/appearance.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MPL-2.0 AND MIT //! Change the appearance of menus. -use iced_core::{border::Radius, Background, Color}; +use iced_core::{Background, Color, border::Radius}; /// The appearance of a menu. #[derive(Debug, Clone, Copy)] diff --git a/src/widget/dropdown/menu/mod.rs b/src/widget/dropdown/menu/mod.rs index e3c056f8..0c96c1c6 100644 --- a/src/widget/dropdown/menu/mod.rs +++ b/src/widget/dropdown/menu/mod.rs @@ -3,16 +3,20 @@ // SPDX-License-Identifier: MPL-2.0 AND MIT mod appearance; +use std::borrow::Cow; +use std::sync::{Arc, Mutex}; + pub use appearance::{Appearance, StyleSheet}; -use crate::widget::Container; +use crate::surface; +use crate::widget::{Container, RcWrapper, icon}; use iced_core::event::{self, Event}; use iced_core::layout::{self, Layout}; use iced_core::text::{self, Text}; use iced_core::widget::Tree; use iced_core::{ - alignment, mouse, overlay, renderer, svg, touch, Border, Clipboard, Element, Length, Padding, - Pixels, Point, Rectangle, Renderer, Shadow, Shell, Size, Vector, Widget, + Border, Clipboard, Element, Length, Padding, Pixels, Point, Rectangle, Renderer, Shadow, Shell, + Size, Vector, Widget, alignment, mouse, overlay, renderer, svg, touch, }; use iced_widget::scrollable::Scrollable; @@ -21,12 +25,15 @@ use iced_widget::scrollable::Scrollable; pub struct Menu<'a, S, Message> where S: AsRef, + [S]: std::borrow::ToOwned, { - state: &'a mut State, - options: &'a [S], - hovered_option: &'a mut Option, + state: State, + options: Cow<'a, [S]>, + icons: Cow<'a, [icon::Handle]>, + hovered_option: Arc>>, selected_option: Option, on_selected: Box Message + 'a>, + close_on_selected: Option, on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, width: f32, padding: Padding, @@ -35,20 +42,26 @@ where style: (), } -impl<'a, S: AsRef, Message: 'a> Menu<'a, S, Message> { +impl<'a, S: AsRef, Message: 'a + std::clone::Clone> Menu<'a, S, Message> +where + [S]: std::borrow::ToOwned, +{ /// Creates a new [`Menu`] with the given [`State`], a list of options, and /// the message to produced when an option is selected. pub fn new( - state: &'a mut State, - options: &'a [S], - hovered_option: &'a mut Option, + state: State, + options: Cow<'a, [S]>, + icons: Cow<'a, [icon::Handle]>, + hovered_option: Arc>>, selected_option: Option, on_selected: impl FnMut(usize) -> Message + 'a, on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, + close_on_selected: Option, ) -> Self { Menu { state, options, + icons, hovered_option, selected_option, on_selected: Box::new(on_selected), @@ -58,6 +71,7 @@ impl<'a, S: AsRef, Message: 'a> Menu<'a, S, Message> { text_size: None, text_line_height: text::LineHeight::default(), style: Default::default(), + close_on_selected, } } @@ -97,22 +111,33 @@ impl<'a, S: AsRef, Message: 'a> Menu<'a, S, Message> { position: Point, target_height: f32, ) -> overlay::Element<'a, Message, crate::Theme, crate::Renderer> { - overlay::Element::new(position, Box::new(Overlay::new(self, target_height))) + overlay::Element::new(Box::new(Overlay::new(self, target_height, position))) + } + + /// Turns the [`Menu`] into a popup [`Element`] at the given target + /// position. + /// + /// The `target_height` will be used to display the menu either on top + /// of the target or under it, depending on the screen position and the + /// dimensions of the [`Menu`]. + #[must_use] + pub fn popup(self, position: Point, target_height: f32) -> crate::Element<'a, Message> { + Overlay::new(self, target_height, position).into() } } /// The local state of a [`Menu`]. #[must_use] -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct State { - tree: Tree, + pub(crate) tree: RcWrapper, } impl State { /// Creates a new [`State`] for a [`Menu`]. pub fn new() -> Self { Self { - tree: Tree::empty(), + tree: RcWrapper::new(Tree::empty()), } } } @@ -124,18 +149,27 @@ impl Default for State { } struct Overlay<'a, Message> { - state: &'a mut Tree, + state: RcWrapper, container: Container<'a, Message, crate::Theme, crate::Renderer>, width: f32, target_height: f32, style: (), + position: Point, } -impl<'a, Message: 'a> Overlay<'a, Message> { - pub fn new>(menu: Menu<'a, S, Message>, target_height: f32) -> Self { +impl<'a, Message: Clone + 'a> Overlay<'a, Message> { + pub fn new>( + menu: Menu<'a, S, Message>, + target_height: f32, + position: Point, + ) -> Self + where + [S]: ToOwned, + { let Menu { state, options, + icons, hovered_option, selected_option, on_selected, @@ -145,52 +179,48 @@ impl<'a, Message: 'a> Overlay<'a, Message> { text_size, text_line_height, style, + close_on_selected, } = menu; - let mut container = Container::new(Scrollable::new(List { - options, - hovered_option, - selected_option, - on_selected, - on_option_hovered, - text_size, - text_line_height, - padding, - })); + let mut container = Container::new(Scrollable::new( + Container::new(List { + options, + icons, + hovered_option, + selected_option, + on_selected, + close_on_selected, + on_option_hovered, + text_size, + text_line_height, + padding, + }) + .padding(padding), + )) + .class(crate::style::Container::Dropdown); - container = container - .padding(padding) - .style(crate::style::Container::Dropdown); - - state.tree.diff(&mut container as &mut dyn Widget<_, _, _>); + state + .tree + .with_data_mut(|tree| tree.diff(&mut container as &mut dyn Widget<_, _, _>)); Self { - state: &mut state.tree, + state: state.tree, container, width, target_height, style, + position, } } -} -impl<'a, Message> iced_core::Overlay - for Overlay<'a, Message> -{ - fn layout( - &mut self, - renderer: &crate::Renderer, - bounds: Size, - position: Point, - _translation: iced::Vector, - ) -> layout::Node { - let space_below = bounds.height - (position.y + self.target_height); - let space_above = position.y; + fn _layout(&mut self, renderer: &crate::Renderer, bounds: Size) -> layout::Node { + let space_below = bounds.height - (self.position.y + self.target_height); + let space_above = self.position.y; let limits = layout::Limits::new( Size::ZERO, Size::new( - bounds.width - position.x, + bounds.width - self.position.x, if space_below > space_above { space_below } else { @@ -200,43 +230,50 @@ impl<'a, Message> iced_core::Overlay ) .width(self.width); - let node = self.container.layout(self.state, renderer, &limits); + let node = self + .state + .with_data_mut(|tree| self.container.layout(tree, renderer, &limits)); - node.clone().move_to(if space_below > space_above { - position + Vector::new(0.0, self.target_height) + let node_size = node.size(); + node.move_to(if space_below > space_above { + self.position + Vector::new(0.0, self.target_height) } else { - position - Vector::new(0.0, node.size().height) + self.position - Vector::new(0.0, node_size.height) }) } - fn on_event( + fn _update( &mut self, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - ) -> event::Status { + ) { let bounds = layout.bounds(); - self.container.on_event( - self.state, event, layout, cursor, renderer, clipboard, shell, &bounds, - ) + self.state.with_data_mut(|tree| { + self.container.update( + tree, event, layout, cursor, renderer, clipboard, shell, &bounds, + ) + }) } - fn mouse_interaction( + fn _mouse_interaction( &self, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, renderer: &crate::Renderer, ) -> mouse::Interaction { - self.container - .mouse_interaction(self.state, layout, cursor, viewport, renderer) + self.state.with_data(|tree| { + self.container + .mouse_interaction(tree, layout, cursor, viewport, renderer) + }) } - fn draw( + fn _draw( &self, renderer: &mut crate::Renderer, theme: &crate::Theme, @@ -256,35 +293,149 @@ impl<'a, Message> iced_core::Overlay radius: appearance.border_radius, }, shadow: Shadow::default(), + snap: true, }, appearance.background, ); - self.container - .draw(self.state, renderer, theme, style, layout, cursor, &bounds); + self.state.with_data(|tree| { + self.container + .draw(tree, renderer, theme, style, layout, cursor, &bounds) + }) } } -struct List<'a, S: AsRef, Message> { - options: &'a [S], - hovered_option: &'a mut Option, +impl<'a, Message: Clone + 'a> iced_core::Overlay + for Overlay<'a, Message> +{ + fn layout(&mut self, renderer: &crate::Renderer, bounds: Size) -> layout::Node { + self._layout(renderer, bounds) + } + + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + self._update(event, layout, cursor, renderer, clipboard, shell) + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + self._mouse_interaction(layout, cursor, &layout.bounds(), renderer) + } + + fn draw( + &self, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + self._draw(renderer, theme, style, layout, cursor); + } +} + +impl<'a, Message: Clone + 'a> crate::widget::Widget + for Overlay<'a, Message> +{ + fn size(&self) -> Size { + Size::new(Length::Fixed(self.width), Length::Shrink) + } + + fn layout( + &mut self, + _tree: &mut iced_core::widget::Tree, + renderer: &crate::Renderer, + limits: &iced::Limits, + ) -> layout::Node { + let limits = limits.width(self.width); + + self.state + .with_data_mut(|tree| self.container.layout(tree, renderer, &limits)) + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + self._mouse_interaction(layout, cursor, viewport, renderer) + } + + fn update( + &mut self, + _tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) { + self._update(event, layout, cursor, renderer, clipboard, shell) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + self._draw(renderer, theme, style, layout, cursor); + } +} + +impl<'a, Message: Clone + 'a> From> for crate::Element<'a, Message> { + fn from(widget: Overlay<'a, Message>) -> Self { + Element::new(widget) + } +} + +struct List<'a, S: AsRef, Message> +where + [S]: std::borrow::ToOwned, +{ + options: Cow<'a, [S]>, + icons: Cow<'a, [icon::Handle]>, + hovered_option: Arc>>, selected_option: Option, on_selected: Box Message + 'a>, + close_on_selected: Option, on_option_hovered: Option<&'a dyn Fn(usize) -> Message>, padding: Padding, text_size: Option, text_line_height: text::LineHeight, } -impl<'a, S: AsRef, Message> Widget - for List<'a, S, Message> +impl, Message> Widget for List<'_, S, Message> +where + [S]: std::borrow::ToOwned, + Message: Clone, { fn size(&self) -> Size { Size::new(Length::Fill, Length::Shrink) } fn layout( - &self, + &mut self, _tree: &mut Tree, renderer: &crate::Renderer, limits: &layout::Limits, @@ -301,7 +452,7 @@ impl<'a, S: AsRef, Message> Widget let size = { let intrinsic = Size::new( 0.0, - (f32::from(text_line_height) + self.padding.vertical()) * self.options.len() as f32, + (f32::from(text_line_height) + self.padding.y()) * self.options.len() as f32, ); limits.resolve(Length::Fill, Length::Shrink, intrinsic) @@ -310,23 +461,28 @@ impl<'a, S: AsRef, Message> Widget layout::Node::new(size) } - fn on_event( + fn update( &mut self, _state: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &crate::Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &Rectangle, - ) -> event::Status { + ) { match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + let hovered_guard = self.hovered_option.lock().unwrap(); if cursor.is_over(layout.bounds()) { - if let Some(index) = *self.hovered_option { + if let Some(index) = *hovered_guard { shell.publish((self.on_selected)(index)); - return event::Status::Captured; + if let Some(close_on_selected) = self.close_on_selected.as_ref() { + shell.publish(close_on_selected.clone()); + } + shell.capture_event(); + return; } } } @@ -338,17 +494,18 @@ impl<'a, S: AsRef, Message> Widget let option_height = f32::from(self.text_line_height.to_absolute(Pixels(text_size))) - + self.padding.vertical(); + + self.padding.y(); let new_hovered_option = (cursor_position.y / option_height) as usize; + let mut hovered_guard = self.hovered_option.lock().unwrap(); if let Some(on_option_hovered) = self.on_option_hovered { - if *self.hovered_option != Some(new_hovered_option) { + if *hovered_guard != Some(new_hovered_option) { shell.publish(on_option_hovered(new_hovered_option)); } } - *self.hovered_option = Some(new_hovered_option); + *hovered_guard = Some(new_hovered_option); } } Event::Touch(touch::Event::FingerPressed { .. }) => { @@ -359,20 +516,23 @@ impl<'a, S: AsRef, Message> Widget let option_height = f32::from(self.text_line_height.to_absolute(Pixels(text_size))) - + self.padding.vertical(); + + self.padding.y(); + let mut hovered_guard = self.hovered_option.lock().unwrap(); - *self.hovered_option = Some((cursor_position.y / option_height) as usize); + *hovered_guard = Some((cursor_position.y / option_height) as usize); - if let Some(index) = *self.hovered_option { + if let Some(index) = *hovered_guard { shell.publish((self.on_selected)(index)); - return event::Status::Captured; + if let Some(close_on_selected) = self.close_on_selected.as_ref() { + shell.publish(close_on_selected.clone()); + } + shell.capture_event(); + return; } } } _ => {} } - - event::Status::Ignored } fn mouse_interaction( @@ -394,12 +554,12 @@ impl<'a, S: AsRef, Message> Widget fn draw( &self, - _state: &Tree, + state: &Tree, renderer: &mut crate::Renderer, theme: &crate::Theme, - _style: &renderer::Style, + style: &renderer::Style, layout: Layout<'_>, - _cursor: mouse::Cursor, + cursor: mouse::Cursor, viewport: &Rectangle, ) { let appearance = theme.appearance(&()); @@ -408,8 +568,8 @@ impl<'a, S: AsRef, Message> Widget let text_size = self .text_size .unwrap_or_else(|| text::Renderer::default_size(renderer).0); - let option_height = f32::from(self.text_line_height.to_absolute(Pixels(text_size))) - + self.padding.vertical(); + let option_height = + f32::from(self.text_line_height.to_absolute(Pixels(text_size))) + self.padding.y(); let offset = viewport.y - bounds.y; let start = (offset / option_height) as usize; @@ -427,6 +587,8 @@ impl<'a, S: AsRef, Message> Widget height: option_height, }; + let hovered_guard = self.hovered_option.lock().unwrap(); + let (color, font) = if self.selected_option == Some(i) { let item_x = bounds.x + appearance.border_width; let item_width = appearance.border_width.mul_add(-2.0, bounds.width); @@ -443,24 +605,26 @@ impl<'a, S: AsRef, Message> Widget ..Default::default() }, shadow: Shadow::default(), + snap: true, }, appearance.selected_background, ); - svg::Renderer::draw( - renderer, - crate::widget::common::object_select().clone(), - Some(appearance.selected_text_color), - Rectangle { - x: item_x + item_width - 16.0 - 8.0, - y: bounds.y + (bounds.height / 2.0 - 8.0), - width: 16.0, - height: 16.0, - }, - ); + let svg_handle = + iced_core::Svg::new(crate::widget::common::object_select().clone()) + .color(appearance.selected_text_color) + .border_radius(appearance.border_radius); + + let bounds = Rectangle { + x: item_x + item_width - 16.0 - 8.0, + y: bounds.y + (bounds.height / 2.0 - 8.0), + width: 16.0, + height: 16.0, + }; + svg::Renderer::draw_svg(renderer, svg_handle, bounds, bounds); (appearance.selected_text_color, crate::font::semibold()) - } else if *self.hovered_option == Some(i) { + } else if *hovered_guard == Some(i) { let item_x = bounds.x + appearance.border_width; let item_width = appearance.border_width.mul_add(-2.0, bounds.width); @@ -476,6 +640,7 @@ impl<'a, S: AsRef, Message> Widget ..Default::default() }, shadow: Shadow::default(), + snap: true, }, appearance.hovered_background, ); @@ -485,24 +650,38 @@ impl<'a, S: AsRef, Message> Widget (appearance.text_color, crate::font::default()) }; - let bounds = Rectangle { + let mut bounds = Rectangle { x: bounds.x + self.padding.left, y: bounds.center_y(), width: f32::INFINITY, ..bounds }; + + if let Some(handle) = self.icons.get(i) { + let icon_bounds = Rectangle { + x: bounds.x, + y: bounds.y + 8.0 - (bounds.height / 2.0), + width: 20.0, + height: 20.0, + }; + + bounds.x += 24.0; + icon::draw(renderer, handle, icon_bounds); + } + text::Renderer::fill_text( renderer, Text { - content: option.as_ref(), + content: option.as_ref().to_string(), bounds: bounds.size(), size: Pixels(text_size), line_height: self.text_line_height, font, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), }, bounds.position(), color, @@ -514,6 +693,9 @@ impl<'a, S: AsRef, Message> Widget impl<'a, S: AsRef, Message: 'a> From> for Element<'a, Message, crate::Theme, crate::Renderer> +where + [S]: std::borrow::ToOwned, + Message: Clone, { fn from(list: List<'a, S, Message>) -> Self { Element::new(list) diff --git a/src/widget/dropdown/mod.rs b/src/widget/dropdown/mod.rs index 6e3db648..b5fd4c06 100644 --- a/src/widget/dropdown/mod.rs +++ b/src/widget/dropdown/mod.rs @@ -4,19 +4,64 @@ //! Displays a list of options in a popover menu on select. +use std::borrow::Cow; + pub mod menu; pub use menu::Menu; pub mod multi; +pub mod operation; mod widget; pub use widget::*; +use crate::surface; +pub use iced_core::widget::Id; +use iced_core::window; + /// Displays a list of options in a popover menu on select. -pub fn dropdown<'a, S: AsRef, Message: 'a>( - selections: &'a [S], +pub fn dropdown< + 'a, + S: AsRef + std::clone::Clone + Send + Sync + 'static, + Message: 'static + Clone, +>( + selections: impl Into>, selected: Option, - on_selected: impl Fn(usize) -> Message + 'a, -) -> Dropdown<'a, S, Message> { - Dropdown::new(selections, selected, on_selected) + on_selected: impl Fn(usize) -> Message + Send + Sync + 'static, +) -> Dropdown<'a, S, Message, Message> { + Dropdown::new(selections.into(), selected, on_selected) } + +/// Displays a list of options in a popover menu on select. +/// AppMessage must be the App's toplevel message. +pub fn popup_dropdown< + 'a, + S: AsRef + std::clone::Clone + Send + Sync + 'static, + Message: 'static + Clone, + AppMessage: 'static + Clone, +>( + selections: impl Into>, + selected: Option, + on_selected: impl Fn(usize) -> Message + Send + Sync + 'static, + _parent_id: window::Id, + _on_surface_action: impl Fn(surface::Action) -> Message + Send + Sync + 'static, + _map_action: impl Fn(Message) -> AppMessage + Send + Sync + 'static, +) -> Dropdown<'a, S, Message, AppMessage> { + let dropdown: Dropdown<'_, S, Message, AppMessage> = + Dropdown::new(selections.into(), selected, on_selected); + + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + let dropdown = dropdown.with_popup(_parent_id, _on_surface_action, _map_action); + + dropdown +} + +// /// Produces a [`Task`] that closes the [`Dropdown`]. +// pub fn close(id: Id) -> iced_runtime::Task { +// iced_runtime::task::effect(iced_runtime::Action::Widget(Box::new(operation::close(id)))) +// } + +// /// Produces a [`Task`] that opens the [`Dropdown`]. +// pub fn open(id: Id) -> iced_runtime::Task { +// iced_runtime::task::effect(iced_runtime::Action::Widget(Box::new(operation::open(id)))) +// } diff --git a/src/widget/dropdown/multi/menu.rs b/src/widget/dropdown/multi/menu.rs index 3710698d..0a761097 100644 --- a/src/widget/dropdown/multi/menu.rs +++ b/src/widget/dropdown/multi/menu.rs @@ -7,8 +7,8 @@ use iced_core::layout::{self, Layout}; use iced_core::text::{self, Text}; use iced_core::widget::Tree; use iced_core::{ - alignment, mouse, overlay, renderer, svg, touch, Border, Clipboard, Element, Length, Padding, - Pixels, Point, Rectangle, Renderer, Shadow, Shell, Size, Vector, Widget, + Border, Clipboard, Element, Length, Padding, Pixels, Point, Rectangle, Renderer, Shadow, Shell, + Size, Vector, Widget, alignment, mouse, overlay, renderer, svg, touch, }; use iced_widget::scrollable::Scrollable; @@ -97,7 +97,7 @@ where position: Point, target_height: f32, ) -> overlay::Element<'a, Message, crate::Theme, crate::Renderer> { - overlay::Element::new(position, Box::new(Overlay::new(self, target_height))) + overlay::Element::new(Box::new(Overlay::new(self, target_height, position))) } } @@ -129,12 +129,14 @@ struct Overlay<'a, Message> { width: f32, target_height: f32, style: (), + position: Point, } impl<'a, Message: 'a> Overlay<'a, Message> { pub fn new, Item: Clone + PartialEq>( menu: Menu<'a, S, Item, Message>, target_height: f32, + position: Point, ) -> Self { let Menu { state, @@ -150,20 +152,20 @@ impl<'a, Message: 'a> Overlay<'a, Message> { style, } = menu; - let mut container = Container::new(Scrollable::new(InnerList { - options, - hovered_option, - selected_option, - on_selected, - on_option_hovered, - padding, - text_size, - text_line_height, - })); - - container = container - .padding(padding) - .style(crate::style::Container::Dropdown); + let mut container = Container::new(Scrollable::new( + Container::new(InnerList { + options, + hovered_option, + selected_option, + on_selected, + on_option_hovered, + padding, + text_size, + text_line_height, + }) + .padding(padding), + )) + .class(crate::style::Container::Dropdown); state.tree.diff(&mut container as &mut dyn Widget<_, _, _>); @@ -173,20 +175,14 @@ impl<'a, Message: 'a> Overlay<'a, Message> { width, target_height, style, + position, } } } -impl<'a, Message> iced_core::Overlay - for Overlay<'a, Message> -{ - fn layout( - &mut self, - renderer: &crate::Renderer, - bounds: Size, - position: Point, - _translation: iced::Vector, - ) -> layout::Node { +impl iced_core::Overlay for Overlay<'_, Message> { + fn layout(&mut self, renderer: &crate::Renderer, bounds: Size) -> layout::Node { + let position = self.position; let space_below = bounds.height - (position.y + self.target_height); let space_above = position.y; @@ -203,29 +199,28 @@ impl<'a, Message> iced_core::Overlay ) .width(self.width); - let mut node = self.container.layout(self.state, renderer, &limits); + let node = self.container.layout(self.state, renderer, &limits); - node = node.clone().move_to(if space_below > space_above { + let node_size = node.size(); + node.move_to(if space_below > space_above { position + Vector::new(0.0, self.target_height) } else { - position - Vector::new(0.0, node.size().height) - }); - - node + position - Vector::new(0.0, node_size.height) + }) } - fn on_event( + fn update( &mut self, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - ) -> event::Status { + ) { let bounds = layout.bounds(); - self.container.on_event( + self.container.update( self.state, event, layout, cursor, renderer, clipboard, shell, &bounds, ) } @@ -234,11 +229,10 @@ impl<'a, Message> iced_core::Overlay &self, layout: Layout<'_>, cursor: mouse::Cursor, - viewport: &Rectangle, renderer: &crate::Renderer, ) -> mouse::Interaction { self.container - .mouse_interaction(self.state, layout, cursor, viewport, renderer) + .mouse_interaction(self.state, layout, cursor, &layout.bounds(), renderer) } fn draw( @@ -261,6 +255,7 @@ impl<'a, Message> iced_core::Overlay radius: appearance.border_radius, }, shadow: Shadow::default(), + snap: true, }, appearance.background, ); @@ -281,8 +276,8 @@ struct InnerList<'a, S, Item, Message> { text_line_height: text::LineHeight, } -impl<'a, S, Item, Message> Widget - for InnerList<'a, S, Item, Message> +impl Widget + for InnerList<'_, S, Item, Message> where S: AsRef, Item: Clone + PartialEq, @@ -292,7 +287,7 @@ where } fn layout( - &self, + &mut self, _tree: &mut Tree, renderer: &crate::Renderer, limits: &layout::Limits, @@ -314,7 +309,7 @@ where ) }); - let vertical_padding = self.padding.vertical(); + let vertical_padding = self.padding.y(); let text_line_height = f32::from(text_line_height); let size = { @@ -333,17 +328,17 @@ where layout::Node::new(size) } - fn on_event( + fn update( &mut self, _state: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &crate::Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &Rectangle, - ) -> event::Status { + ) { let bounds = layout.bounds(); match event { @@ -351,7 +346,8 @@ where if cursor.is_over(bounds) { if let Some(item) = self.hovered_option.as_ref() { shell.publish((self.on_selected)(item.clone())); - return event::Status::Captured; + shell.capture_event(); + return; } } } @@ -366,7 +362,7 @@ where let heights = self .options - .element_heights(self.padding.vertical(), text_line_height); + .element_heights(self.padding.y(), text_line_height); let mut current_offset = 0.0; @@ -413,7 +409,7 @@ where let heights = self .options - .element_heights(self.padding.vertical(), text_line_height); + .element_heights(self.padding.y(), text_line_height); let mut current_offset = 0.0; @@ -451,8 +447,6 @@ where } _ => {} } - - event::Status::Ignored } fn mouse_interaction( @@ -495,7 +489,7 @@ where let text_line_height = f32::from(self.text_line_height.to_absolute(Pixels(text_size))); let visible_options = self.options.visible_options( - self.padding.vertical(), + self.padding.y(), text_line_height, offset, viewport.height, @@ -517,7 +511,7 @@ where OptionElement::Option((option, item)) => { let (color, font) = if self.selected_option.as_ref() == Some(&item) { let item_x = bounds.x + appearance.border_width; - let item_width = bounds.width - appearance.border_width * 2.0; + let item_width = appearance.border_width.mul_add(-2.0, bounds.width); bounds = Rectangle { x: item_x, @@ -533,26 +527,28 @@ where ..Default::default() }, shadow: Shadow::default(), + snap: true, }, appearance.selected_background, ); - svg::Renderer::draw( - renderer, - crate::widget::common::object_select().clone(), - Some(appearance.selected_text_color), - Rectangle { - x: item_x + item_width - 16.0 - 8.0, - y: bounds.y + (bounds.height / 2.0 - 8.0), - width: 16.0, - height: 16.0, - }, - ); + let svg_bounds = Rectangle { + x: item_x + item_width - 16.0 - 8.0, + y: bounds.y + (bounds.height / 2.0 - 8.0), + width: 16.0, + height: 16.0, + }; + + let svg_handle = + svg::Svg::new(crate::widget::common::object_select().clone()) + .color(appearance.selected_text_color) + .border_radius(appearance.border_radius); + svg::Renderer::draw_svg(renderer, svg_handle, svg_bounds, svg_bounds); (appearance.selected_text_color, crate::font::semibold()) } else if self.hovered_option.as_ref() == Some(item) { let item_x = bounds.x + appearance.border_width; - let item_width = bounds.width - appearance.border_width * 2.0; + let item_width = appearance.border_width.mul_add(-2.0, bounds.width); bounds = Rectangle { x: item_x, @@ -568,6 +564,7 @@ where ..Default::default() }, shadow: Shadow::default(), + snap: true, }, appearance.hovered_background, ); @@ -587,15 +584,16 @@ where text::Renderer::fill_text( renderer, Text { - content: option.as_ref(), + content: option.as_ref().to_string(), bounds: bounds.size(), size: iced::Pixels(text_size), line_height: self.text_line_height, font, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), }, bounds.position(), color, @@ -612,7 +610,7 @@ where }) .move_to(Point { x: bounds.x, - y: bounds.y + (self.padding.vertical() / 2.0) - 4.0, + y: bounds.y + (self.padding.y() / 2.0) - 4.0, }); Widget::::draw( @@ -636,15 +634,16 @@ where text::Renderer::fill_text( renderer, Text { - content: description.as_ref(), + content: description.as_ref().to_string(), bounds: bounds.size(), size: iced::Pixels(text_size), line_height: text::LineHeight::Absolute(Pixels(text_line_height + 4.0)), font: crate::font::default(), - horizontal_alignment: alignment::Horizontal::Center, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Center, + align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), }, bounds.position(), appearance.description_color, @@ -674,8 +673,8 @@ pub(super) enum OptionElement<'a, S, Item> { } impl Model { - pub(super) fn elements(&self) -> impl Iterator> + '_ { - let iterator = self.lists.iter().flat_map(|list| { + pub(super) fn elements(&self) -> impl Iterator> + '_ { + self.lists.iter().flat_map(|list| { let description = list .description .as_ref() @@ -687,9 +686,7 @@ impl Model { description .chain(options) .chain(std::iter::once(OptionElement::Separator)) - }); - - iterator + }) } fn element_heights( @@ -710,7 +707,7 @@ impl Model { text_line_height: f32, offset: f32, height: f32, - ) -> impl Iterator, f32)> + '_ { + ) -> impl Iterator, f32)> + '_ { let heights = self.element_heights(padding_vertical, text_line_height); let mut current = 0.0; diff --git a/src/widget/dropdown/multi/mod.rs b/src/widget/dropdown/multi/mod.rs index 2f3a68e5..543001c9 100644 --- a/src/widget/dropdown/multi/mod.rs +++ b/src/widget/dropdown/multi/mod.rs @@ -3,13 +3,13 @@ // SPDX-License-Identifier: MPL-2.0 AND MIT mod model; -pub use model::{list, model, List, Model}; +pub use model::{List, Model, list, model}; pub mod menu; pub use menu::Menu; mod widget; -pub use widget::{Appearance, Dropdown, StyleSheet}; +pub use widget::{Catalog, Dropdown, Style}; pub fn dropdown<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static>( model: &'a Model, diff --git a/src/widget/dropdown/multi/model.rs b/src/widget/dropdown/multi/model.rs index 12bf4269..f67f8edd 100644 --- a/src/widget/dropdown/multi/model.rs +++ b/src/widget/dropdown/multi/model.rs @@ -66,9 +66,7 @@ impl Model { } pub(super) fn next(&self) -> Option<&(S, Item)> { - let Some(item) = self.selected.as_ref() else { - return None; - }; + let item = self.selected.as_ref()?; let mut next = false; for list in &self.lists { diff --git a/src/widget/dropdown/multi/widget.rs b/src/widget/dropdown/multi/widget.rs index a97f03f2..779c6d00 100644 --- a/src/widget/dropdown/multi/widget.rs +++ b/src/widget/dropdown/multi/widget.rs @@ -8,11 +8,14 @@ use derive_setters::Setters; use iced_core::event::{self, Event}; use iced_core::text::{self, Paragraph, Text}; use iced_core::widget::tree::{self, Tree}; -use iced_core::{alignment, keyboard, layout, mouse, overlay, renderer, svg, touch, Shadow}; -use iced_core::{Clipboard, Layout, Length, Padding, Pixels, Rectangle, Shell, Size, Widget}; +use iced_core::{ + Clipboard, Layout, Length, Padding, Pixels, Rectangle, Shell, Size, Vector, Widget, +}; +use iced_core::{Shadow, alignment, keyboard, layout, mouse, overlay, renderer, svg, touch}; +use iced_widget::pick_list; use std::ffi::OsStr; -pub use iced_widget::style::pick_list::{Appearance, StyleSheet}; +pub use iced_widget::pick_list::{Catalog, Style}; /// A widget for selecting a single value from a list of selections. #[derive(Setters)] @@ -75,7 +78,7 @@ impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &crate::Renderer, limits: &layout::Limits, @@ -98,7 +101,7 @@ impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> for (_, item) in &list.options { state .selections - .push((item.clone(), crate::Paragraph::new())); + .push((item.clone(), crate::Plain::default())); } } } @@ -113,17 +116,17 @@ impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> ) } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &crate::Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &Rectangle, - ) -> event::Status { + ) { update( &event, layout, @@ -132,7 +135,7 @@ impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> self.on_selected.as_ref(), self.selections, || tree.state.downcast_mut::>(), - ) + ); } fn mouse_interaction( @@ -156,7 +159,7 @@ impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> cursor: mouse::Cursor, viewport: &Rectangle, ) { - let font = self.font.unwrap_or_else(|| crate::font::default()); + let font = self.font.unwrap_or_else(crate::font::default); draw( renderer, @@ -180,8 +183,10 @@ impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &crate::Renderer, + _viewport: &Rectangle, + translation: Vector, ) -> Option> { let state = tree.state.downcast_mut::>(); @@ -196,6 +201,7 @@ impl<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static> self.text_line_height, self.selections, &self.on_selected, + translation, ) } } @@ -216,8 +222,8 @@ pub struct State { keyboard_modifiers: keyboard::Modifiers, is_open: bool, hovered_option: Option, - selections: Vec<(Item, crate::Paragraph)>, - descriptions: Vec, + selections: Vec<(Item, crate::Plain)>, + descriptions: Vec, } impl State { @@ -225,10 +231,6 @@ impl State { pub fn new() -> Self { Self { icon: match icon::from_name("pan-down-symbolic").size(16).handle().data { - icon::Data::Name(named) => named - .path() - .filter(|path| path.extension().is_some_and(|ext| ext == OsStr::new("svg"))) - .map(iced_core::svg::Handle::from_path), icon::Data::Svg(handle) => Some(handle), icon::Data::Image(_) => None, }, @@ -259,7 +261,7 @@ pub fn layout( text_size: f32, text_line_height: text::LineHeight, font: Option, - selection: Option<(&str, &mut crate::Paragraph)>, + selection: Option<(&str, &mut crate::Plain)>, ) -> layout::Node { use std::f32; @@ -267,17 +269,18 @@ pub fn layout( let max_width = match width { Length::Shrink => { - let measure = move |(label, paragraph): (_, &mut crate::Paragraph)| -> f32 { + let measure = move |(label, paragraph): (_, &mut crate::Plain)| -> f32 { paragraph.update(Text { content: label, bounds: Size::new(f32::MAX, f32::MAX), size: iced::Pixels(text_size), line_height: text_line_height, - font: font.unwrap_or_else(|| crate::font::default()), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Top, + font: font.unwrap_or_else(crate::font::default), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), }); paragraph.min_width().round() }; @@ -312,7 +315,7 @@ pub fn update<'a, S: AsRef, Message, Item: Clone + PartialEq + 'static + 'a on_selected: &dyn Fn(Item) -> Message, selections: &super::Model, state: impl FnOnce() -> &'a mut State, -) -> event::Status { +) { match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { @@ -323,14 +326,12 @@ pub fn update<'a, S: AsRef, Message, Item: Clone + PartialEq + 'static + 'a // bounds or on the drop-down, either way we close the overlay. state.is_open = false; - event::Status::Captured + shell.capture_event(); } else if cursor.is_over(layout.bounds()) { state.is_open = true; state.hovered_option = selections.selected.clone(); - event::Status::Captured - } else { - event::Status::Ignored + shell.capture_event(); } } Event::Mouse(mouse::Event::WheelScrolled { @@ -346,19 +347,15 @@ pub fn update<'a, S: AsRef, Message, Item: Clone + PartialEq + 'static + 'a shell.publish((on_selected)(option.1.clone())); } - event::Status::Captured - } else { - event::Status::Ignored + shell.capture_event(); } } Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { let state = state(); state.keyboard_modifiers = *modifiers; - - event::Status::Ignored } - _ => event::Status::Ignored, + _ => {} } } @@ -388,6 +385,7 @@ pub fn overlay<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static text_line_height: text::LineHeight, selections: &'a super::Model, on_selected: &'a dyn Fn(Item) -> Message, + translation: Vector, ) -> Option> { if state.is_open { let description_line_height = text::LineHeight::Absolute(Pixels( @@ -410,67 +408,71 @@ pub fn overlay<'a, S: AsRef, Message: 'a, Item: Clone + PartialEq + 'static ) .width({ let measure = - |label: &str, paragraph: &mut crate::Paragraph, line_height: text::LineHeight| { + |label: &str, paragraph: &mut crate::Plain, line_height: text::LineHeight| { paragraph.update(Text { content: label, bounds: Size::new(f32::MAX, f32::MAX), size: iced::Pixels(text_size), line_height, - font: font.unwrap_or_else(|| crate::font::default()), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Top, + font: font.unwrap_or_else(crate::font::default), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), }); paragraph.min_width().round() }; let mut desc_count = 0; - selections - .elements() - .map(|element| match element { - super::menu::OptionElement::Description(desc) => { - let paragraph = if state.descriptions.len() > desc_count { - &mut state.descriptions[desc_count] - } else { - state.descriptions.push(crate::Paragraph::new()); - state.descriptions.last_mut().unwrap() - }; - desc_count += 1; - measure(desc.as_ref(), paragraph, description_line_height) - } + padding.x().mul_add( + 2.0, + selections + .elements() + .map(|element| match element { + super::menu::OptionElement::Description(desc) => { + let paragraph = if state.descriptions.len() > desc_count { + &mut state.descriptions[desc_count] + } else { + state.descriptions.push(crate::Plain::default()); + state.descriptions.last_mut().unwrap() + }; + desc_count += 1; + measure(desc.as_ref(), paragraph, description_line_height) + } - super::menu::OptionElement::Option((option, item)) => { - let selection_index = state.selections.iter().position(|(i, _)| i == item); + super::menu::OptionElement::Option((option, item)) => { + let selection_index = + state.selections.iter().position(|(i, _)| i == item); - let selection_index = match selection_index { - Some(index) => index, - None => { - state - .selections - .push((item.clone(), crate::Paragraph::new())); - state.selections.len() - 1 - } - }; + let selection_index = match selection_index { + Some(index) => index, + None => { + state + .selections + .push((item.clone(), crate::Plain::default())); + state.selections.len() - 1 + } + }; - let paragraph = &mut state.selections[selection_index].1; + let paragraph = &mut state.selections[selection_index].1; - measure(option.as_ref(), paragraph, text_line_height) - } + measure(option.as_ref(), paragraph, text_line_height) + } - super::menu::OptionElement::Separator => 1.0, - }) - .fold(0.0, |next, current| current.max(next)) - + gap + super::menu::OptionElement::Separator => 1.0, + }) + .fold(0.0, |next, current| current.max(next)), + ) + gap + 16.0 - + (padding.horizontal() * 2.0) }) .padding(padding) .text_size(text_size); let mut position = layout.position(); position.x -= padding.left; - + position.x += translation.x; + position.y += translation.y; Some(menu.overlay(position, bounds.height)) } else { None @@ -499,9 +501,9 @@ pub fn draw<'a, S, Item: Clone + PartialEq + 'static>( let is_mouse_over = cursor.is_over(bounds); let style = if is_mouse_over { - theme.hovered(&()) + theme.style(&(), pick_list::Status::Hovered) } else { - theme.active(&()) + theme.style(&(), pick_list::Status::Active) }; iced_core::Renderer::fill_quad( @@ -510,22 +512,20 @@ pub fn draw<'a, S, Item: Clone + PartialEq + 'static>( bounds, border: style.border, shadow: Shadow::default(), + snap: true, }, style.background, ); - if let Some(handle) = state.icon.clone() { - svg::Renderer::draw( - renderer, - handle, - Some(style.text_color), - Rectangle { - x: bounds.x + bounds.width - gap - 16.0, - y: bounds.center_y() - 8.0, - width: 16.0, - height: 16.0, - }, - ); + if let Some(handle) = state.icon.as_ref() { + let svg_handle = iced_core::Svg::new(handle.clone()).color(style.text_color); + let svg_bounds = Rectangle { + x: bounds.x + bounds.width - gap - 16.0, + y: bounds.center_y() - 8.0, + width: 16.0, + height: 16.0, + }; + svg::Renderer::draw_svg(renderer, svg_handle, svg_bounds, svg_bounds); } if let Some(content) = selected.map(AsRef::as_ref) { @@ -534,22 +534,23 @@ pub fn draw<'a, S, Item: Clone + PartialEq + 'static>( let bounds = Rectangle { x: bounds.x + padding.left, y: bounds.center_y(), - width: bounds.width - padding.horizontal(), + width: bounds.width - padding.x(), height: f32::from(text_line_height.to_absolute(Pixels(text_size))), }; text::Renderer::fill_text( renderer, Text { - content, + content: content.to_string(), size: iced::Pixels(text_size), line_height: text_line_height, font, bounds: bounds.size(), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), }, bounds.position(), style.text_color, diff --git a/src/widget/dropdown/operation.rs b/src/widget/dropdown/operation.rs new file mode 100644 index 00000000..1a4e1a9f --- /dev/null +++ b/src/widget/dropdown/operation.rs @@ -0,0 +1,72 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 AND MIT +//! Operate on dropdown widgets. + +use super::State; +use iced::Rectangle; +use iced_core::widget::{Id, Operation}; + +pub trait Dropdown { + fn close(&mut self); + fn open(&mut self); +} + +// /// Produces a [`Task`] that closes a [`Dropdown`] popup. +// pub fn close(id: Id) -> impl Operation { +// struct Close(Id); + +// impl Operation for Close { +// fn custom(&mut self, state: &mut dyn std::any::Any, id: Option<&Id>) { +// if id.map_or(true, |id| id != &self.0) { +// return; +// } + +// let Some(state) = state.downcast_mut::() else { +// return; +// }; + +// state.close(); +// } + +// fn container( +// &mut self, +// _id: Option<&Id>, +// _bounds: Rectangle, +// operate_on_children: &mut dyn FnMut(&mut dyn Operation), +// ) { +// operate_on_children(self) +// } +// } + +// Close(id) +// } + +// /// Produces a [`Task`] that opens a [`Dropdown`] popup. +// pub fn open(id: Id) -> impl Operation { +// struct Open(Id); + +// impl Operation for Open { +// fn custom(&mut self, state: &mut dyn std::any::Any, id: Option<&Id>) { +// if id.map_or(true, |id| id != &self.0) { +// return; +// } + +// let Some(state) = state.downcast_mut::() else { +// return; +// }; + +// state.open(); +// } + +// fn container( +// &mut self, +// _id: Option<&Id>, +// _bounds: Rectangle, +// operate_on_children: &mut dyn FnMut(&mut dyn Operation), +// ) { +// operate_on_children(self) +// } +// } + +// Open(id) +// } diff --git a/src/widget/dropdown/widget.rs b/src/widget/dropdown/widget.rs index 971072ca..2ff9c92f 100644 --- a/src/widget/dropdown/widget.rs +++ b/src/widget/dropdown/widget.rs @@ -2,26 +2,44 @@ // Copyright 2019 Héctor Ramón, Iced contributors // SPDX-License-Identifier: MPL-2.0 AND MIT +use super::Id; use super::menu::{self, Menu}; -use crate::widget::icon; +use crate::widget::icon::{self, Handle}; +use crate::{Element, surface}; use derive_setters::Setters; +use iced::window; use iced_core::event::{self, Event}; use iced_core::text::{self, Paragraph, Text}; use iced_core::widget::tree::{self, Tree}; -use iced_core::{alignment, keyboard, layout, mouse, overlay, renderer, svg, touch, Shadow}; -use iced_core::{Clipboard, Layout, Length, Padding, Pixels, Rectangle, Shell, Size, Widget}; +use iced_core::{ + Clipboard, Layout, Length, Padding, Pixels, Rectangle, Shell, Size, Vector, Widget, +}; +use iced_core::{Shadow, alignment, keyboard, layout, mouse, overlay, renderer, svg, touch}; +use iced_widget::pick_list::{self, Catalog}; +use std::borrow::Cow; use std::ffi::OsStr; use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; -pub use iced_widget::style::pick_list::{Appearance, StyleSheet}; +pub type DropdownView = Arc Element<'static, Message> + Send + Sync>; +static AUTOSIZE_ID: LazyLock = + LazyLock::new(|| crate::widget::Id::new("cosmic-applet-autosize")); /// A widget for selecting a single value from a list of selections. #[derive(Setters)] -pub struct Dropdown<'a, S: AsRef, Message> { +pub struct Dropdown<'a, S: AsRef + Send + Sync + Clone + 'static, Message, AppMessage> +where + [S]: std::borrow::ToOwned, +{ #[setters(skip)] - on_selected: Box Message + 'a>, + id: Option, #[setters(skip)] - selections: &'a [S], + on_selected: Arc Message + Send + Sync>, + #[setters(skip)] + selections: Cow<'a, [S]>, + #[setters] + icons: Cow<'a, [icon::Handle]>, #[setters(skip)] selected: Option, #[setters(into)] @@ -29,14 +47,28 @@ pub struct Dropdown<'a, S: AsRef, Message> { gap: f32, #[setters(into)] padding: Padding, + #[setters(strip_option, into)] + placeholder: Option>, #[setters(strip_option)] text_size: Option, text_line_height: text::LineHeight, #[setters(strip_option)] font: Option, + #[setters(skip)] + on_surface_action: Option Message + Send + Sync + 'static>>, + #[setters(skip)] + action_map: Option AppMessage + 'static + Send + Sync>>, + #[setters(strip_option)] + window_id: Option, + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, } -impl<'a, S: AsRef, Message> Dropdown<'a, S, Message> { +impl<'a, S: AsRef + Send + Sync + Clone + 'static, Message: 'static, AppMessage: 'static> + Dropdown<'a, S, Message, AppMessage> +where + [S]: std::borrow::ToOwned, +{ /// The default gap. pub const DEFAULT_GAP: f32 = 4.0; @@ -46,26 +78,99 @@ impl<'a, S: AsRef, Message> Dropdown<'a, S, Message> { /// Creates a new [`Dropdown`] with the given list of selections, the current /// selected value, and the message to produce when an option is selected. pub fn new( - selections: &'a [S], + selections: Cow<'a, [S]>, selected: Option, - on_selected: impl Fn(usize) -> Message + 'a, + on_selected: impl Fn(usize) -> Message + 'static + Send + Sync, ) -> Self { Self { - on_selected: Box::new(on_selected), + id: None, + on_selected: Arc::new(on_selected), selections, + icons: Cow::Borrowed(&[]), selected, + placeholder: None, width: Length::Shrink, gap: Self::DEFAULT_GAP, padding: Self::DEFAULT_PADDING, text_size: None, text_line_height: text::LineHeight::Relative(1.2), font: None, + window_id: None, + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner::default(), + on_surface_action: None, + action_map: None, } } + + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + /// Handle dropdown requests for popup creation. + /// Intended to be used with [`crate::app::message::get_popup`] + pub fn with_popup( + self, + parent_id: window::Id, + on_surface_action: impl Fn(surface::Action) -> Message + Send + Sync + 'static, + action_map: impl Fn(Message) -> NewAppMessage + Send + Sync + 'static, + ) -> Dropdown<'a, S, Message, NewAppMessage> { + let Self { + id, + on_selected, + selections, + icons, + selected, + placeholder, + width, + gap, + padding, + text_size, + text_line_height, + font, + positioner, + .. + } = self; + + Dropdown::<'a, S, Message, NewAppMessage> { + id, + on_selected, + selections, + icons, + selected, + placeholder, + width, + gap, + padding, + text_size, + text_line_height, + font, + on_surface_action: Some(Arc::new(on_surface_action)), + action_map: Some(Arc::new(action_map)), + window_id: Some(parent_id), + positioner, + } + } + + pub fn id(mut self, id: Id) -> Self { + self.id = Some(id); + self + } + + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + pub fn with_positioner( + mut self, + positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, + ) -> Self { + self.positioner = positioner; + self + } } -impl<'a, S: AsRef, Message: 'a> Widget - for Dropdown<'a, S, Message> +impl< + S: AsRef + Send + Sync + Clone + 'static, + Message: 'static + Clone, + AppMessage: 'static + Clone, +> Widget for Dropdown<'_, S, Message, AppMessage> +where + [S]: std::borrow::ToOwned, { fn tag(&self) -> tree::Tag { tree::Tag::of::() @@ -78,9 +183,11 @@ impl<'a, S: AsRef, Message: 'a> Widget(); + let mut selections_changed = state.selections.len() != self.selections.len(); + state .selections - .resize_with(self.selections.len(), crate::Paragraph::new); + .resize_with(self.selections.len(), crate::Plain::default); state.hashes.resize(self.selections.len(), 0); for (i, selection) in self.selections.iter().enumerate() { @@ -92,20 +199,27 @@ impl<'a, S: AsRef, Message: 'a> Widget Size { @@ -113,7 +227,7 @@ impl<'a, S: AsRef, Message: 'a> Widget, Message: 'a> Widget().selections.get_mut(id)) }), + self.placeholder.as_deref(), + !self.icons.is_empty(), ) } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &crate::Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &Rectangle, - ) -> event::Status { - update( + ) { + update::( &event, layout, cursor, shell, - self.on_selected.as_ref(), + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + self.positioner.clone(), + self.on_selected.clone(), self.selected, - self.selections, + &self.selections, || tree.state.downcast_mut::(), + self.window_id, + self.on_surface_action.clone(), + self.action_map.clone(), + &self.icons, + self.gap, + self.padding, + self.text_size, + self.font, + self.selected, ) } @@ -180,7 +307,7 @@ impl<'a, S: AsRef, Message: 'a> Widget, Message: 'a> Widget(), viewport, ); } + fn operate( + &mut self, + tree: &mut Tree, + _layout: Layout<'_>, + _renderer: &crate::Renderer, + operation: &mut dyn iced_core::widget::Operation, + ) { + // TODO: double check operation handling + // let state = tree.state.downcast_mut::(); + // operation.custom(state, self.id.as_ref()); + } + fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &crate::Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + if self.window_id.is_some() || self.on_surface_action.is_some() { + return None; + } + let state = tree.state.downcast_mut::(); overlay( @@ -214,31 +362,54 @@ impl<'a, S: AsRef, Message: 'a> Widget, + // state: &Tree, + // p: mouse::Cursor, + // ) -> iced_accessibility::A11yTree { + // // TODO + // } } -impl<'a, S: AsRef, Message: 'a> From> - for crate::Element<'a, Message> +impl< + 'a, + S: AsRef + Send + Sync + Clone + 'static, + Message: 'static + std::clone::Clone, + AppMessage: 'static + std::clone::Clone, +> From> for crate::Element<'a, Message> +where + [S]: std::borrow::ToOwned, { - fn from(pick_list: Dropdown<'a, S, Message>) -> Self { + fn from(pick_list: Dropdown<'a, S, Message, AppMessage>) -> Self { Self::new(pick_list) } } /// The local state of a [`Dropdown`]. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct State { icon: Option, menu: menu::State, keyboard_modifiers: keyboard::Modifiers, - is_open: bool, - hovered_option: Option, - selections: Vec, + is_open: Arc, + close_operation: bool, + open_operation: bool, + hovered_option: Arc>>, hashes: Vec, + selections: Vec, + popup_id: window::Id, } impl State { @@ -246,19 +417,18 @@ impl State { pub fn new() -> Self { Self { icon: match icon::from_name("pan-down-symbolic").size(16).handle().data { - icon::Data::Name(named) => named - .path() - .filter(|path| path.extension().is_some_and(|ext| ext == OsStr::new("svg"))) - .map(iced_core::svg::Handle::from_path), icon::Data::Svg(handle) => Some(handle), icon::Data::Image(_) => None, }, menu: menu::State::default(), keyboard_modifiers: keyboard::Modifiers::default(), - is_open: false, - hovered_option: None, + is_open: Arc::new(AtomicBool::new(false)), + hovered_option: Arc::new(Mutex::new(None)), selections: Vec::new(), hashes: Vec::new(), + popup_id: window::Id::unique(), + close_operation: false, + open_operation: false, } } } @@ -269,6 +439,16 @@ impl Default for State { } } +impl super::operation::Dropdown for State { + fn close(&mut self) { + self.close_operation = true; + } + + fn open(&mut self) { + self.open_operation = true; + } +} + /// Computes the layout of a [`Dropdown`]. #[allow(clippy::too_many_arguments)] pub fn layout( @@ -280,7 +460,9 @@ pub fn layout( text_size: f32, text_line_height: text::LineHeight, font: Option, - selection: Option<(&str, &mut crate::Paragraph)>, + selection: Option<(&str, &mut crate::Plain)>, + placeholder: Option<&str>, + has_icons: bool, ) -> layout::Node { use std::f32; @@ -288,29 +470,57 @@ pub fn layout( let max_width = match width { Length::Shrink => { - let measure = move |(label, paragraph): (_, &mut crate::Paragraph)| -> f32 { - paragraph.update(Text { - content: label, - bounds: Size::new(f32::MAX, f32::MAX), - size: iced::Pixels(text_size), - line_height: text_line_height, - font: font.unwrap_or_else(|| crate::font::default()), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Top, - shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), - }); + let measure = move |(label, paragraph): (_, Option<&mut crate::Plain>)| -> f32 { + let paragraph = match paragraph { + Some(p) => { + let text = Text { + content: label, + bounds: Size::new(f32::MAX, f32::MAX), + size: iced::Pixels(text_size), + line_height: text_line_height, + font: font.unwrap_or_else(crate::font::default), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), + }; + p.update(text); + p + } + None => { + let text = Text { + content: label.to_string(), + bounds: Size::new(f32::MAX, f32::MAX), + size: iced::Pixels(text_size), + line_height: text_line_height, + font: font.unwrap_or_else(crate::font::default), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), + }; + &mut crate::Plain::new(text) + } + }; paragraph.min_width().round() }; - selection.map(measure).unwrap_or_default() + selection + .map(|(l, p)| (l, Some(p))) + .or_else(|| placeholder.map(|l| (l, None))) + .map(measure) + .unwrap_or_default() } _ => 0.0, }; + let icon_size = if has_icons { 24.0 } else { 0.0 }; + let size = { let intrinsic = Size::new( - max_width + gap + 16.0, + max_width + icon_size + gap + 16.0, f32::from(text_line_height.to_absolute(Pixels(text_size))), ); @@ -324,65 +534,182 @@ pub fn layout( /// Processes an [`Event`] and updates the [`State`] of a [`Dropdown`] /// accordingly. -#[allow(clippy::too_many_arguments)] -pub fn update<'a, S: AsRef, Message>( +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub fn update< + 'a, + S: AsRef + Send + Sync + Clone + 'static, + Message: Clone + 'static, + AppMessage: Clone + 'static, +>( event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, shell: &mut Shell<'_, Message>, - on_selected: &dyn Fn(usize) -> Message, + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, + on_selected: Arc Message + Send + Sync + 'static>, selected: Option, selections: &[S], state: impl FnOnce() -> &'a mut State, -) -> event::Status { + _window_id: Option, + on_surface_action: Option Message + Send + Sync + 'static>>, + action_map: Option AppMessage + Send + Sync + 'static>>, + icons: &[icon::Handle], + gap: f32, + padding: Padding, + text_size: Option, + font: Option, + selected_option: Option, +) { + let state = state(); + + let open = |shell: &mut Shell<'_, Message>, + state: &mut State, + on_selected: Arc Message + Send + Sync + 'static>| { + state.is_open.store(true, Ordering::Relaxed); + let mut hovered_guard = state.hovered_option.lock().unwrap(); + *hovered_guard = selected; + let id = window::Id::unique(); + state.popup_id = id; + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + if let Some(((on_surface_action, parent), action_map)) = on_surface_action + .as_ref() + .zip(_window_id) + .zip(action_map.clone()) + { + use iced_runtime::platform_specific::wayland::popup::{ + SctkPopupSettings, SctkPositioner, + }; + let bounds = layout.bounds(); + let anchor_rect = Rectangle { + x: bounds.x as i32, + y: bounds.y as i32, + width: bounds.width as i32, + height: bounds.height as i32, + }; + let icon_width = if icons.is_empty() { 0.0 } else { 24.0 }; + let measure = |_label: &str, selection_paragraph: &crate::Paragraph| -> f32 { + selection_paragraph.min_width().round() + }; + let pad_width = padding.x().mul_add(2.0, 16.0); + + let selections_width = selections + .iter() + .zip(state.selections.iter_mut()) + .map(|(label, selection)| measure(label.as_ref(), selection.raw())) + .fold(0.0, |next, current| current.max(next)); + + let icons: Cow<'static, [Handle]> = Cow::Owned(icons.to_vec()); + let selections: Cow<'static, [S]> = Cow::Owned(selections.to_vec()); + let state = state.clone(); + let on_close = surface::action::destroy_popup(id); + let on_surface_action_clone = on_surface_action.clone(); + let translation = layout.virtual_offset(); + let get_popup_action = surface::action::simple_popup::( + move || { + SctkPopupSettings { + parent, + id, + input_zone: None, + positioner: SctkPositioner { + size: Some((selections_width as u32 + gap as u32 + pad_width as u32 + icon_width as u32, 10)), + anchor_rect, + // TODO: left or right alignment based on direction? + anchor: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::BottomLeft, + gravity: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight, + reactive: true, + offset: ((-padding.left - translation.x) as i32, -translation.y as i32), + constraint_adjustment: 9, + ..Default::default() + }, + parent_size: None, + grab: true, + close_with_children: true, + } + }, + Some(Box::new(move || { + let action_map = action_map.clone(); + let on_selected = on_selected.clone(); + let e: Element<'static, crate::Action> = + Element::from(menu_widget( + bounds, + &state, + gap, + padding, + text_size.unwrap_or(14.0), + selections.clone(), + icons.clone(), + selected_option, + Arc::new(move |i| on_selected.clone()(i)), + Some(on_surface_action_clone(on_close.clone())), + )) + .map(move |m| crate::Action::App(action_map.clone()(m))); + e + })), + ); + shell.publish(on_surface_action(get_popup_action)); + } + }; + + let is_open = state.is_open.load(Ordering::Relaxed); + let refresh = state.close_operation && state.open_operation; + + if state.close_operation { + state.close_operation = false; + state.is_open.store(false, Ordering::SeqCst); + if is_open { + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + if let Some(ref on_close) = on_surface_action { + shell.publish(on_close(surface::action::destroy_popup(state.popup_id))); + } + } + } + + if state.open_operation { + state.open_operation = false; + state.is_open.store(true, Ordering::SeqCst); + if (refresh && is_open) || (!refresh && !is_open) { + open(shell, state, on_selected.clone()); + } + } + match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { - let state = state(); - - if state.is_open { + let is_open = state.is_open.load(Ordering::Relaxed); + if is_open { // Event wasn't processed by overlay, so cursor was clicked either outside it's // bounds or on the drop-down, either way we close the overlay. - state.is_open = false; - - event::Status::Captured + state.is_open.store(false, Ordering::Relaxed); + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + if let Some(on_close) = on_surface_action { + shell.publish(on_close(surface::action::destroy_popup(state.popup_id))); + } + shell.capture_event(); } else if cursor.is_over(layout.bounds()) { - state.is_open = true; - state.hovered_option = selected; - - event::Status::Captured - } else { - event::Status::Ignored + open(shell, state, on_selected); + shell.capture_event(); } } Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { .. }, }) => { - let state = state(); + let is_open = state.is_open.load(Ordering::Relaxed); - if state.keyboard_modifiers.command() - && cursor.is_over(layout.bounds()) - && !state.is_open - { + if state.keyboard_modifiers.command() && cursor.is_over(layout.bounds()) && !is_open { let next_index = selected.map(|index| index + 1).unwrap_or_default(); if selections.len() < next_index { shell.publish((on_selected)(next_index)); } - event::Status::Captured - } else { - event::Status::Ignored + shell.capture_event(); } } Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { - let state = state(); - state.keyboard_modifiers = *modifiers; - - event::Status::Ignored } - _ => event::Status::Ignored, + _ => {} } } @@ -399,9 +726,72 @@ pub fn mouse_interaction(layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::In } } +#[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] +/// Returns the current menu widget of a [`Dropdown`]. +#[allow(clippy::too_many_arguments)] +pub fn menu_widget< + S: AsRef + Send + Sync + Clone + 'static, + Message: 'static + std::clone::Clone, +>( + bounds: Rectangle, + state: &State, + gap: f32, + padding: Padding, + text_size: f32, + selections: Cow<'static, [S]>, + icons: Cow<'static, [icon::Handle]>, + selected_option: Option, + on_selected: Arc Message + Send + Sync + 'static>, + close_on_selected: Option, +) -> crate::Element<'static, Message> +where + [S]: std::borrow::ToOwned, +{ + let icon_width = if icons.is_empty() { 0.0 } else { 24.0 }; + let measure = |_label: &str, selection_paragraph: &crate::Paragraph| -> f32 { + selection_paragraph.min_width().round() + }; + let selections_width = selections + .iter() + .zip(state.selections.iter()) + .map(|(label, selection)| measure(label.as_ref(), selection.raw())) + .fold(0.0, |next, current| current.max(next)); + let pad_width = padding.x().mul_add(2.0, 16.0); + + let width = selections_width + gap + pad_width + icon_width; + let is_open = state.is_open.clone(); + let menu: Menu<'static, S, Message> = Menu::new( + state.menu.clone(), + selections, + icons, + state.hovered_option.clone(), + selected_option, + move |option| { + is_open.store(false, Ordering::Relaxed); + + (on_selected)(option) + }, + None, + close_on_selected, + ) + .width(width) + .padding(padding) + .text_size(text_size); + + crate::widget::autosize::autosize( + menu.popup(iced::Point::new(0., 0.), bounds.height), + AUTOSIZE_ID.clone(), + ) + .auto_height(true) + .auto_width(true) + .min_height(1.) + .min_width(width) + .into() +} + /// Returns the current overlay of a [`Dropdown`]. #[allow(clippy::too_many_arguments)] -pub fn overlay<'a, S: AsRef, Message: 'a>( +pub fn overlay<'a, S: AsRef + Send + Sync + Clone + 'static, Message: std::clone::Clone + 'a>( layout: Layout<'_>, _renderer: &crate::Renderer, state: &'a mut State, @@ -411,44 +801,57 @@ pub fn overlay<'a, S: AsRef, Message: 'a>( _text_line_height: text::LineHeight, _font: Option, selections: &'a [S], + icons: &'a [icon::Handle], selected_option: Option, on_selected: &'a dyn Fn(usize) -> Message, -) -> Option> { - if state.is_open { + translation: Vector, + close_on_selected: Option, +) -> Option> +where + [S]: std::borrow::ToOwned, +{ + if state.is_open.load(Ordering::Relaxed) { let bounds = layout.bounds(); let menu = Menu::new( - &mut state.menu, - selections, - &mut state.hovered_option, + state.menu.clone(), + Cow::Borrowed(selections), + Cow::Borrowed(icons), + state.hovered_option.clone(), selected_option, |option| { - state.is_open = false; + state.is_open.store(false, Ordering::Relaxed); (on_selected)(option) }, None, + close_on_selected, ) .width({ - let measure = |_label: &str, selection_paragraph: &mut crate::Paragraph| -> f32 { + let measure = |_label: &str, selection_paragraph: &crate::Paragraph| -> f32 { selection_paragraph.min_width().round() }; + let pad_width = padding.x().mul_add(2.0, 16.0); + + let icon_width = if icons.is_empty() { 0.0 } else { 24.0 }; + selections .iter() .zip(state.selections.iter_mut()) - .map(|(label, selection)| measure(label.as_ref(), selection)) + .map(|(label, selection)| measure(label.as_ref(), selection.raw())) .fold(0.0, |next, current| current.max(next)) + gap - + 16.0 - + padding.horizontal() - + padding.horizontal() + + pad_width + + icon_width }) .padding(padding) .text_size(text_size); let mut position = layout.position(); position.x -= padding.left; + position.x += translation.x; + position.y += translation.y; Some(menu.overlay(position, bounds.height)) } else { None @@ -468,6 +871,8 @@ pub fn draw<'a, S>( text_line_height: text::LineHeight, font: crate::font::Font, selected: Option<&'a S>, + icon: Option<&'a icon::Handle>, + placeholder: Option<&'a str>, state: &'a State, viewport: &Rectangle, ) where @@ -477,9 +882,9 @@ pub fn draw<'a, S>( let is_mouse_over = cursor.is_over(bounds); let style = if is_mouse_over { - theme.hovered(&()) + theme.style(&(), pick_list::Status::Hovered) } else { - theme.active(&()) + theme.style(&(), pick_list::Status::Active) }; iced_core::Renderer::fill_quad( @@ -488,44 +893,57 @@ pub fn draw<'a, S>( bounds, border: style.border, shadow: Shadow::default(), + snap: true, }, style.background, ); if let Some(handle) = state.icon.clone() { - svg::Renderer::draw( - renderer, - handle, - Some(style.text_color), - Rectangle { - x: bounds.x + bounds.width - gap - 16.0, - y: bounds.center_y() - 8.0, - width: 16.0, - height: 16.0, - }, - ); + let svg_handle = svg::Svg::new(handle).color(style.text_color); + let bounds = Rectangle { + x: bounds.x + bounds.width - gap - 16.0, + y: bounds.center_y() - 8.0, + width: 16.0, + height: 16.0, + }; + svg::Renderer::draw_svg(renderer, svg_handle, bounds, bounds); } - if let Some(content) = selected.map(AsRef::as_ref) { + if let Some(content) = selected.map(AsRef::as_ref).or(placeholder) { let text_size = text_size.unwrap_or_else(|| text::Renderer::default_size(renderer).0); - let bounds = Rectangle { + + let mut bounds = Rectangle { x: bounds.x + padding.left, y: bounds.center_y(), - width: bounds.width - padding.horizontal(), + width: bounds.width - padding.x(), height: f32::from(text_line_height.to_absolute(Pixels(text_size))), }; + + if let Some(handle) = icon { + let icon_bounds = Rectangle { + x: bounds.x, + y: bounds.y - (bounds.height / 2.0) - 2.0, + width: 20.0, + height: 20.0, + }; + + bounds.x += 24.0; + icon::draw(renderer, handle, icon_bounds); + } + text::Renderer::fill_text( renderer, Text { - content, + content: content.to_string(), size: iced::Pixels(text_size), line_height: text_line_height, font, bounds: bounds.size(), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::default(), + ellipsize: text::Ellipsize::default(), }, bounds.position(), style.text_color, diff --git a/src/widget/flex_row/layout.rs b/src/widget/flex_row/layout.rs index d781e4f9..166b47f4 100644 --- a/src/widget/flex_row/layout.rs +++ b/src/widget/flex_row/layout.rs @@ -15,7 +15,7 @@ use taffy::{AlignContent, TaffyTree}; pub fn resolve( renderer: &Renderer, limits: &Limits, - items: &[Element<'_, Message>], + items: &mut [Element<'_, Message>], padding: Padding, column_spacing: f32, row_spacing: f32, @@ -44,7 +44,7 @@ pub fn resolve( min_size: taffy::geometry::Size { width: length(max_size.width), - height: Dimension::Auto, + height: Dimension::auto(), }, align_items, @@ -61,8 +61,8 @@ pub fn resolve( ..taffy::Style::default() }; - for (child, tree) in items.iter().zip(tree.iter_mut()) { - let child_widget = child.as_widget(); + for (child, tree) in items.iter_mut().zip(tree.iter_mut()) { + let child_widget = child.as_widget_mut(); let child_node = child_widget.layout(tree, renderer, limits); let size = child_node.size(); @@ -71,7 +71,7 @@ pub fn resolve( let c_size = child_widget.size(); let (width, flex_grow, justify_self) = match c_size.width { Length::Fill | Length::FillPortion(_) => { - (Dimension::Auto, 1.0, Some(AlignItems::Stretch)) + (Dimension::auto(), 1.0, Some(AlignItems::Stretch)) } _ => (length(size.width), 0.0, None), }; @@ -82,15 +82,15 @@ pub fn resolve( min_size: taffy::geometry::Size { width: match min_item_width { Some(width) => length(size.width.min(width)), - None => Dimension::Auto, + None => Dimension::auto(), }, - height: Dimension::Auto, + height: Dimension::auto(), }, size: taffy::geometry::Size { width, height: match c_size.height { - Length::Fill | Length::FillPortion(_) => Dimension::Auto, + Length::Fill | Length::FillPortion(_) => Dimension::auto(), _ => length(size.height), }, }, @@ -138,7 +138,7 @@ pub fn resolve( leafs .into_iter() - .zip(items.iter()) + .zip(items.iter_mut()) .zip(nodes.iter_mut()) .zip(tree) .for_each(|(((leaf, child), node), tree)| { @@ -146,7 +146,7 @@ pub fn resolve( return; }; - let child_widget = child.as_widget(); + let child_widget = child.as_widget_mut(); let c_size = child_widget.size(); match c_size.width { Length::Fill | Length::FillPortion(_) => { @@ -156,15 +156,20 @@ pub fn resolve( _ => (), } - *node = node.clone().move_to(Point { + node.move_to_mut(Point { x: leaf_layout.location.x, y: leaf_layout.location.y, }); }); + let actual_height = nodes + .iter() + .map(|node| node.bounds().y + node.bounds().height) + .fold(0.0f32, f32::max); + let size = Size { width: flex_layout.content_size.width, - height: flex_layout.content_size.height, + height: actual_height.max(flex_layout.content_size.height), }; Node::with_children(size, nodes) diff --git a/src/widget/flex_row/widget.rs b/src/widget/flex_row/widget.rs index 9f19ca1c..0b2e6e13 100644 --- a/src/widget/flex_row/widget.rs +++ b/src/widget/flex_row/widget.rs @@ -6,11 +6,11 @@ use derive_setters::Setters; use iced_core::event::{self, Event}; use iced_core::widget::{Operation, Tree}; use iced_core::{ - layout, mouse, overlay, renderer, Clipboard, Layout, Length, Padding, Rectangle, Shell, Widget, + Clipboard, Layout, Length, Padding, Rectangle, Shell, Vector, Widget, layout, mouse, overlay, + renderer, }; -use iced_renderer::core::widget::OperationOutputWrapper; -/// Responsively generates rows and columns of widgets based on its dimmensions. +/// Responsively generates rows and columns of widgets based on its dimensions. #[derive(Setters)] #[must_use] pub struct FlexRow<'a, Message> { @@ -78,6 +78,7 @@ impl<'a, Message> FlexRow<'a, Message> { } /// Sets the space between each column and row. + #[inline] pub const fn spacing(mut self, spacing: u16) -> Self { self.column_spacing = spacing; self.row_spacing = spacing; @@ -85,9 +86,7 @@ impl<'a, Message> FlexRow<'a, Message> { } } -impl<'a, Message: 'static + Clone> Widget - for FlexRow<'a, Message> -{ +impl Widget for FlexRow<'_, Message> { fn children(&self) -> Vec { self.children.iter().map(Tree::new).collect() } @@ -101,7 +100,7 @@ impl<'a, Message: 'static + Clone> Widget } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, @@ -115,66 +114,69 @@ impl<'a, Message: 'static + Clone> Widget super::layout::resolve( renderer, &limits, - &self.children, + &mut self.children, self.padding, f32::from(self.column_spacing), f32::from(self.row_spacing), self.min_item_width, - self.align_items, self.justify_items, + self.align_items, self.justify_content, &mut tree.children, ) } fn operate( - &self, + &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation>, + operation: &mut dyn Operation<()>, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.traverse(&mut |operation| { self.children - .iter() + .iter_mut() .zip(&mut tree.children) .zip(layout.children()) - .for_each(|((child, state), layout)| { - child - .as_widget() - .operate(state, layout, renderer, operation); + .for_each(|((child, state), c_layout)| { + child.as_widget_mut().operate( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + operation, + ); }); }); } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { - self.children + ) { + for ((child, state), c_layout) in self + .children .iter_mut() .zip(&mut tree.children) .zip(layout.children()) - .map(|((child, state), layout)| { - child.as_widget_mut().on_event( - state, - event.clone(), - layout, - cursor, - renderer, - clipboard, - shell, - viewport, - ) - }) - .fold(event::Status::Ignored, event::Status::merge) + { + child.as_widget_mut().update( + state, + event, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } } fn mouse_interaction( @@ -189,10 +191,14 @@ impl<'a, Message: 'static + Clone> Widget .iter() .zip(&tree.children) .zip(layout.children()) - .map(|((child, state), layout)| { - child - .as_widget() - .mouse_interaction(state, layout, cursor, viewport, renderer) + .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() @@ -208,25 +214,40 @@ impl<'a, Message: 'static + Clone> Widget cursor: mouse::Cursor, viewport: &Rectangle, ) { - for ((child, state), layout) in self + for ((child, state), c_layout) in self .children .iter() .zip(&tree.children) .zip(layout.children()) { - child - .as_widget() - .draw(state, renderer, theme, style, layout, cursor, viewport); + child.as_widget().draw( + state, + renderer, + theme, + style, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + viewport, + ); } } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - overlay::from_children(&mut self.children, tree, layout, renderer) + overlay::from_children( + &mut self.children, + tree, + layout, + renderer, + viewport, + translation, + ) } #[cfg(feature = "a11y")] @@ -252,7 +273,7 @@ impl<'a, Message: 'static + Clone> Widget state: &Tree, layout: Layout<'_>, renderer: &Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { for ((e, layout), state) in self .children diff --git a/src/widget/frames.rs b/src/widget/frames.rs index 7b6783cb..a542cec6 100644 --- a/src/widget/frames.rs +++ b/src/widget/frames.rs @@ -8,26 +8,27 @@ use std::path::Path; use std::time::{Duration, Instant}; use ::image as image_rs; +use iced::Task; +use iced::mouse; use iced_core::image::Renderer as ImageRenderer; use iced_core::mouse::Cursor; -use iced_core::widget::{tree, Tree}; +use iced_core::widget::{Tree, tree}; use iced_core::{ - event, layout, renderer, window, Clipboard, ContentFit, Element, Event, Layout, Length, - Rectangle, Shell, Size, Vector, Widget, + Clipboard, ContentFit, Element, Event, Layout, Length, Rectangle, Rotation, Shell, Size, + Widget, event, layout, renderer, window, }; -use iced_runtime::Command; -use iced_widget::image::{self, Handle}; +use iced_widget::image::{self, FilterMethod, Handle}; +use image_rs::AnimationDecoder; use image_rs::codecs::gif::GifDecoder; use image_rs::codecs::png::PngDecoder; use image_rs::codecs::webp::WebPDecoder; -use image_rs::AnimationDecoder; #[cfg(not(feature = "tokio"))] use iced_futures::futures::{AsyncRead, AsyncReadExt}; #[cfg(feature = "tokio")] use tokio::io::{AsyncRead, AsyncReadExt}; -use super::icon::load_icon; +use crate::widget::icon; #[must_use] /// Creates a new [`AnimatedImage`] with the given [`animated_image::Frames`] @@ -61,6 +62,7 @@ pub struct Frames { } impl fmt::Debug for Frames { + #[cold] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Frames").finish() } @@ -73,13 +75,13 @@ impl Frames { size: u16, theme: Option<&str>, default_fallbacks: bool, - ) -> Command> { + ) -> Task> { let mut name_path_buffer = None; - if let Some(path) = load_icon(name, size, theme) { + if let Some(path) = icon::Named::new(name).size(size).path() { name_path_buffer = Some(path); } else if default_fallbacks { for name in name.rmatch_indices('-').map(|(pos, _)| &name[..pos]) { - if let Some(path) = load_icon(name, size, theme) { + if let Some(path) = icon::Named::new(name).size(size).path() { name_path_buffer = Some(path); break; } @@ -89,37 +91,42 @@ impl Frames { if let Some(name_path_buffer) = name_path_buffer { Self::load_from_path(name_path_buffer) } else { - Command::perform(async { Err(Error::Missing) }, std::convert::identity) + Task::perform(async { Err(Error::Missing) }, std::convert::identity) } } /// Load [`Frames`] from the supplied path - pub fn load_from_path(path: impl AsRef) -> Command> { - #[cfg(feature = "tokio")] - use tokio::fs::File; - #[cfg(feature = "tokio")] - use tokio::io::BufReader; + pub fn load_from_path(path: impl AsRef) -> Task> { + #[inline(never)] + fn inner(path: &Path) -> Task> { + #[cfg(feature = "tokio")] + use tokio::fs::File; + #[cfg(feature = "tokio")] + use tokio::io::BufReader; - #[cfg(not(feature = "tokio"))] - use async_fs::File; - #[cfg(not(feature = "tokio"))] - use iced_futures::futures::io::BufReader; + #[cfg(not(feature = "tokio"))] + use async_fs::File; + #[cfg(not(feature = "tokio"))] + use iced_futures::futures::io::BufReader; - let path = path.as_ref().to_path_buf(); + let path = path.to_path_buf(); - let f = async move { - let image_type = match &path.extension() { - Some(ext) if ext == &OsStr::new("gif") => ImageType::Gif, - Some(ext) if ext == &OsStr::new("apng") => ImageType::Apng, - Some(ext) if ext == &OsStr::new("webp") => ImageType::WebP, - _ => return Err(Error::Extension), + let f = async move { + let image_type = match &path.extension() { + Some(ext) if ext == &OsStr::new("gif") => ImageType::Gif, + Some(ext) if ext == &OsStr::new("apng") => ImageType::Apng, + Some(ext) if ext == &OsStr::new("webp") => ImageType::WebP, + _ => return Err(Error::Extension), + }; + let reader = BufReader::new(File::open(path).await?); + + Frames::from_reader(reader, image_type).await }; - let reader = BufReader::new(File::open(path).await?); - Self::from_reader(reader, image_type).await - }; + Task::perform(f, std::convert::identity) + } - Command::perform(f, std::convert::identity) + inner(path.as_ref()) } /// Decode [`Frames`] from the supplied async reader @@ -139,7 +146,7 @@ impl Frames { match image_type { ImageType::Gif => Self::from_decoder(GifDecoder::new(io::Cursor::new(bytes))?), - ImageType::Apng => Self::from_decoder(PngDecoder::new(io::Cursor::new(bytes))?.apng()), + ImageType::Apng => Self::from_decoder(PngDecoder::new(io::Cursor::new(bytes))?.apng()?), ImageType::WebP => Self::from_decoder(WebPDecoder::new(io::Cursor::new(bytes))?), } } @@ -161,10 +168,10 @@ impl Frames { let first = frames.first().cloned().unwrap(); let total_bytes = frames .iter() - .map(|f| match f.handle.data() { - iced_core::image::Data::Path(_) => 0, - iced_core::image::Data::Bytes(b) => b.len(), - iced_core::image::Data::Rgba { pixels, .. } => pixels.len(), + .map(|f| match &f.handle { + Handle::Path(..) => 0, + Handle::Bytes(_, b) => b.len(), + Handle::Rgba { pixels, .. } => pixels.len(), }) .sum::() .try_into() @@ -189,7 +196,7 @@ impl From for Frame { let delay = frame.delay().into(); - let handle = image::Handle::from_pixels(width, height, frame.into_buffer().into_vec()); + let handle = image::Handle::from_rgba(width, height, frame.into_buffer().into_vec()); Self { delay, handle } } @@ -272,12 +279,8 @@ impl<'a, Message, Renderer> Widget for Animated where Renderer: ImageRenderer, { - fn width(&self) -> Length { - self.width - } - - fn height(&self) -> Length { - self.height + fn size(&self) -> Size { + Size::new(self.width.into(), self.height.into()) } fn tag(&self) -> tree::Tag { @@ -309,30 +312,40 @@ where } } - fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { iced_widget::image::layout( renderer, limits, &self.frames.first.handle, self.width, self.height, + None, self.content_fit, + Rotation::default(), + false, + [0.0; 4], ) } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, - _layout: Layout<'_>, - _cursor_position: Cursor, - _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, + event: &Event, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - ) -> event::Status { + viewport: &Rectangle, + ) { let state = tree.state.downcast_mut::(); - if let Event::Window(_, window::Event::RedrawRequested(now)) = event { + if let Event::Window(window::Event::RedrawRequested(now)) = event { let elapsed = now.duration_since(state.current.started); if elapsed > state.current.frame.delay { @@ -340,15 +353,14 @@ where state.current = self.frames.frames[state.index].clone().into(); - shell.request_redraw(window::RedrawRequest::At(now + state.current.frame.delay)); + shell + .request_redraw_at(window::RedrawRequest::At(*now + state.current.frame.delay)); } else { let remaining = state.current.frame.delay - elapsed; - shell.request_redraw(window::RedrawRequest::At(now + remaining)); + shell.request_redraw_at(window::RedrawRequest::At(*now + remaining)); } } - - event::Status::Ignored } fn draw( @@ -363,37 +375,18 @@ where ) { let state = tree.state.downcast_ref::(); - // Pulled from iced_native::widget::::draw - // - // TODO: export iced_native::widget::image::draw as standalone function - { - let Size { width, height } = renderer.dimensions(&state.current.frame.handle); - let image_size = Size::new(width as f32, height as f32); - - let bounds = layout.bounds(); - let adjusted_fit = self.content_fit.fit(image_size, bounds.size()); - - let render = |renderer: &mut Renderer| { - let offset = Vector::new( - (bounds.width - adjusted_fit.width).max(0.0) / 2.0, - (bounds.height - adjusted_fit.height).max(0.0) / 2.0, - ); - - let drawing_bounds = Rectangle { - width: adjusted_fit.width, - height: adjusted_fit.height, - ..bounds - }; - - renderer.draw(state.current.frame.handle.clone(), drawing_bounds + offset); - }; - - if adjusted_fit.width > bounds.width || adjusted_fit.height > bounds.height { - renderer.with_layer(bounds, render); - } else { - render(renderer); - } - } + iced_widget::image::draw( + renderer, + layout, + &state.current.frame.handle, + None, + iced_core::border::Radius::default(), + self.content_fit, + FilterMethod::default(), + Rotation::default(), + 1.0, + 1.0, + ); } } diff --git a/src/widget/grid/layout.rs b/src/widget/grid/layout.rs index 6423c377..8ed4c0ec 100644 --- a/src/widget/grid/layout.rs +++ b/src/widget/grid/layout.rs @@ -17,7 +17,7 @@ use taffy::{AlignContent, TaffyTree}; pub fn resolve( renderer: &Renderer, limits: &Limits, - items: &[Element<'_, Message>], + items: &mut [Element<'_, Message>], assignments: &[Assignment], width: Length, height: Length, @@ -37,9 +37,13 @@ pub fn resolve( let mut taffy = TaffyTree::<()>::with_capacity(items.len() + 1); // Attach widgets as child nodes. - for ((child, assignment), tree) in items.iter().zip(assignments.iter()).zip(tree.iter_mut()) { + for ((child, assignment), tree) in items + .iter_mut() + .zip(assignments.iter()) + .zip(tree.iter_mut()) + { // Calculate the dimensions of the item. - let child_widget = child.as_widget(); + let child_widget = child.as_widget_mut(); let child_node = child_widget.layout(tree, renderer, limits); let size = child_node.size(); @@ -48,7 +52,7 @@ pub fn resolve( let c_size = child_widget.size(); let (width, flex_grow, justify_self) = match c_size.width { Length::Fill | Length::FillPortion(_) => { - (Dimension::Auto, 1.0, Some(AlignItems::Stretch)) + (Dimension::auto(), 1.0, Some(AlignItems::Stretch)) } _ => (length(size.width), 0.0, None), }; @@ -72,7 +76,7 @@ pub fn resolve( size: taffy::geometry::Size { width, height: match c_size.height { - Length::Fill | Length::FillPortion(_) => Dimension::Auto, + Length::Fill | Length::FillPortion(_) => Dimension::auto(), _ => length(size.height), }, }, @@ -172,12 +176,12 @@ pub fn resolve( for (((leaf, child), node), tree) in leafs .into_iter() - .zip(items.iter()) + .zip(items.iter_mut()) .zip(nodes.iter_mut()) .zip(tree) { if let Ok(leaf_layout) = taffy.layout(leaf) { - let child_widget = child.as_widget(); + let child_widget = child.as_widget_mut(); let c_size = child_widget.size(); match c_size.width { Length::Fill | Length::FillPortion(_) => { @@ -187,7 +191,7 @@ pub fn resolve( _ => (), } - *node = node.clone().move_to(Point { + node.move_to_mut(Point { x: leaf_layout.location.x, y: leaf_layout.location.y, }) diff --git a/src/widget/grid/widget.rs b/src/widget/grid/widget.rs index a043f19d..e59ba90d 100644 --- a/src/widget/grid/widget.rs +++ b/src/widget/grid/widget.rs @@ -6,10 +6,9 @@ use derive_setters::Setters; use iced_core::event::{self, Event}; use iced_core::widget::{Operation, Tree}; use iced_core::{ - layout, mouse, overlay, renderer, Alignment, Clipboard, Layout, Length, Padding, Rectangle, - Shell, Widget, + Alignment, Clipboard, Layout, Length, Padding, Rectangle, Shell, Vector, Widget, layout, mouse, + overlay, renderer, }; -use iced_renderer::core::widget::OperationOutputWrapper; /// Responsively generates rows and columns of widgets based on its dimmensions. #[must_use] @@ -45,6 +44,12 @@ pub struct Grid<'a, Message> { row: u16, } +impl Default for Grid<'_, Message> { + fn default() -> Self { + Self::new() + } +} + impl<'a, Message> Grid<'a, Message> { pub const fn new() -> Self { Self { @@ -100,6 +105,7 @@ impl<'a, Message> Grid<'a, Message> { self } + #[inline] pub fn insert_row(mut self) -> Self { self.row += 1; self.column = 1; @@ -107,7 +113,7 @@ impl<'a, Message> Grid<'a, Message> { } } -impl<'a, Message: 'static + Clone> Widget for Grid<'a, Message> { +impl Widget for Grid<'_, Message> { fn children(&self) -> Vec { self.children.iter().map(Tree::new).collect() } @@ -121,7 +127,7 @@ impl<'a, Message: 'static + Clone> Widget for G } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, @@ -135,7 +141,7 @@ impl<'a, Message: 'static + Clone> Widget for G super::layout::resolve( renderer, &limits, - &self.children, + &mut self.children, &self.assignments, self.width, self.height, @@ -150,53 +156,56 @@ impl<'a, Message: 'static + Clone> Widget for G } fn operate( - &self, + &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation>, + operation: &mut dyn Operation<()>, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.traverse(&mut |operation| { self.children - .iter() + .iter_mut() .zip(&mut tree.children) .zip(layout.children()) - .for_each(|((child, state), layout)| { - child - .as_widget() - .operate(state, layout, renderer, operation); + .for_each(|((child, state), c_layout)| { + child.as_widget_mut().operate( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + operation, + ); }); }); } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { - self.children + ) { + for ((child, state), c_layout) in self + .children .iter_mut() .zip(&mut tree.children) .zip(layout.children()) - .map(|((child, state), layout)| { - child.as_widget_mut().on_event( - state, - event.clone(), - layout, - cursor, - renderer, - clipboard, - shell, - viewport, - ) - }) - .fold(event::Status::Ignored, event::Status::merge) + { + child.as_widget_mut().update( + state, + event, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } } fn mouse_interaction( @@ -211,10 +220,14 @@ impl<'a, Message: 'static + Clone> Widget for G .iter() .zip(&tree.children) .zip(layout.children()) - .map(|((child, state), layout)| { - child - .as_widget() - .mouse_interaction(state, layout, cursor, viewport, renderer) + .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() @@ -230,25 +243,40 @@ impl<'a, Message: 'static + Clone> Widget for G cursor: mouse::Cursor, viewport: &Rectangle, ) { - for ((child, state), layout) in self + for ((child, state), c_layout) in self .children .iter() .zip(&tree.children) .zip(layout.children()) { - child - .as_widget() - .draw(state, renderer, theme, style, layout, cursor, viewport); + child.as_widget().draw( + state, + renderer, + theme, + style, + c_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + viewport, + ); } } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - overlay::from_children(&mut self.children, tree, layout, renderer) + overlay::from_children( + &mut self.children, + tree, + layout, + renderer, + viewport, + translation, + ) } #[cfg(feature = "a11y")] @@ -265,7 +293,13 @@ impl<'a, Message: 'static + Clone> Widget for G .iter() .zip(layout.children()) .zip(state.children.iter()) - .map(|((c, c_layout), state)| c.as_widget().a11y_nodes(c_layout, state, p)), + .map(|((c, c_layout), state)| { + c.as_widget().a11y_nodes( + c_layout.with_virtual_offset(layout.virtual_offset()), + state, + p, + ) + }), ) } @@ -274,16 +308,20 @@ impl<'a, Message: 'static + Clone> Widget for G state: &Tree, layout: Layout<'_>, renderer: &Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { - for ((e, layout), state) in self + for ((e, c_layout), state) in self .children .iter() .zip(layout.children()) .zip(state.children.iter()) { - e.as_widget() - .drag_destinations(state, layout, renderer, dnd_rectangles); + e.as_widget().drag_destinations( + state, + c_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + dnd_rectangles, + ); } } } @@ -303,6 +341,12 @@ pub struct Assignment { pub(super) height: u16, } +impl Default for Assignment { + fn default() -> Self { + Self::new() + } +} + impl Assignment { pub const fn new() -> Self { Self { diff --git a/src/widget/header_bar.rs b/src/widget/header_bar.rs index 1f74510b..a772f7d2 100644 --- a/src/widget/header_bar.rs +++ b/src/widget/header_bar.rs @@ -1,12 +1,11 @@ // Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 -use crate::cosmic_theme::Density; -use crate::{ext::CollectionWidget, widget, Element}; +use crate::cosmic_theme::{Density, Spacing}; +use crate::{Element, theme, widget}; use apply::Apply; use derive_setters::Setters; -use iced::Length; -use iced_core::{widget::tree, Widget}; +use iced_core::{Length, Size, Vector, Widget, layout, text, widget::tree}; use std::borrow::Cow; #[must_use] @@ -23,7 +22,11 @@ pub fn header_bar<'a, Message>() -> HeaderBar<'a, Message> { end: Vec::new(), density: None, focused: false, + maximized: false, + sharp_corners: false, + is_ssd: false, on_double_click: None, + transparent: false, } } @@ -76,6 +79,18 @@ pub struct HeaderBar<'a, Message> { /// Focused state of the window focused: bool, + + /// Maximized state of the window + maximized: bool, + + /// Whether the corners of the window should be sharp + sharp_corners: bool, + + /// HeaderBar used for server-side decorations + is_ssd: bool, + + /// Whether the headerbar should be transparent + transparent: bool, } impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { @@ -106,47 +121,116 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { self.end.push(widget.into()); self } - - /// Build the widget - #[must_use] - pub fn build(self) -> HeaderBarWidget<'a, Message> { - HeaderBarWidget { - header_bar_inner: self.view(), - } - } } pub struct HeaderBarWidget<'a, Message> { - header_bar_inner: Element<'a, Message>, + start: Element<'a, Message>, + center: Option>, + end: Element<'a, Message>, +} + +impl<'a, Message> HeaderBarWidget<'a, Message> { + pub fn new( + start: Element<'a, Message>, + center: Option>, + end: Element<'a, Message>, + ) -> Self { + Self { start, center, end } + } + + fn elems(&self) -> impl Iterator> { + std::iter::once(&self.start) + .chain(std::iter::once(&self.end)) + .chain(self.center.as_ref()) + } + + fn elems_mut(&mut self) -> impl Iterator> { + std::iter::once(&mut self.start) + .chain(std::iter::once(&mut self.end)) + .chain(self.center.as_mut()) + } } impl<'a, Message: Clone + 'static> Widget for HeaderBarWidget<'a, Message> { fn diff(&mut self, tree: &mut tree::Tree) { - tree.diff_children(&mut [&mut self.header_bar_inner]); + if let Some(center) = &mut self.center { + tree.diff_children(&mut [&mut self.start, &mut self.end, center]); + } else { + tree.diff_children(&mut [&mut self.start, &mut self.end]); + } } fn children(&self) -> Vec { - vec![tree::Tree::new(&self.header_bar_inner)] + self.elems().map(tree::Tree::new).collect() } - fn size(&self) -> iced_core::Size { - self.header_bar_inner.as_widget().size() + fn size(&self) -> Size { + Size { + width: Length::Fill, + height: Length::Shrink, + } } fn layout( - &self, + &mut self, tree: &mut tree::Tree, renderer: &crate::Renderer, - limits: &iced_core::layout::Limits, - ) -> iced_core::layout::Node { - let child_tree = &mut tree.children[0]; - let child = self - .header_bar_inner - .as_widget() - .layout(child_tree, renderer, limits); - iced_core::layout::Node::with_children(child.size(), vec![child]) + limits: &layout::Limits, + ) -> layout::Node { + let width = limits.max().width; + let height = limits.max().height; + let gap = 8.0; + + let end_node = + self.end + .as_widget_mut() + .layout(&mut tree.children[1], renderer, &limits.loose()); + let end_width = end_node.size().width; + + let start_available = (width - end_width - gap).max(0.0); + let start_node = self.start.as_widget_mut().layout( + &mut tree.children[0], + renderer, + &layout::Limits::new(Size::ZERO, Size::new(start_available, height)), + ); + let start_width = start_node.size().width; + + let vcenter = |node: layout::Node, x: f32| -> layout::Node { + let dy = ((height - node.size().height) / 2.0).max(0.0); + node.translate(Vector::new(x, dy)) + }; + + let mut child_nodes = Vec::with_capacity(3); + child_nodes.push(vcenter(start_node, 0.0)); + child_nodes.push(vcenter(end_node, width - end_width)); + + if let Some(center) = &mut self.center { + let slot_start = start_width + gap; + let slot_end = (width - end_width - gap).max(slot_start); + let slot_width = slot_end - slot_start; + // this instead of `node.size().width` prevents center jitter as text ellipsizes + let natural_width = center + .as_widget_mut() + .layout(&mut tree.children[2], renderer, &limits.loose()) + .size() + .width; + + let node = center.as_widget_mut().layout( + &mut tree.children[2], + renderer, + &layout::Limits::new(Size::ZERO, Size::new(slot_width, height)), + ); + + let ideal_x = (width - natural_width) / 2.0; + let max_x = (width - end_width - gap - natural_width).max(slot_start); + let center_x = ideal_x.clamp(slot_start, max_x); + + child_nodes.push(vcenter(node, center_x)) + } + + layout::Node::with_children(Size::new(width, height), child_nodes) } fn draw( @@ -159,42 +243,33 @@ impl<'a, Message: Clone + 'static> Widget, cursor: iced_core::mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn iced_core::Clipboard, shell: &mut iced_core::Shell<'_, Message>, viewport: &iced_core::Rectangle, - ) -> iced_core::event::Status { - let child_state = &mut state.children[0]; - let child_layout = layout.children().next().unwrap(); - self.header_bar_inner.as_widget_mut().on_event( - child_state, - event, - child_layout, - cursor, - renderer, - clipboard, - shell, - viewport, - ) + ) { + self.elems_mut() + .zip(&mut state.children) + .zip(layout.children()) + .for_each(|((e, s), l)| { + e.as_widget_mut() + .update(s, event, l, cursor, renderer, clipboard, shell, viewport); + }); } fn mouse_interaction( @@ -205,44 +280,47 @@ impl<'a, Message: Clone + 'static> Widget iced_core::mouse::Interaction { - let child_tree = &state.children[0]; - let child_layout = layout.children().next().unwrap(); - self.header_bar_inner.as_widget().mouse_interaction( - child_tree, - child_layout, - cursor, - viewport, - renderer, - ) + self.elems() + .zip(&state.children) + .zip(layout.children()) + .map(|((e, s), l)| { + e.as_widget() + .mouse_interaction(s, l, cursor, viewport, renderer) + }) + .max() + .unwrap_or(iced_core::mouse::Interaction::None) } fn operate( - &self, + &mut self, state: &mut tree::Tree, layout: iced_core::Layout<'_>, renderer: &crate::Renderer, - operation: &mut dyn iced_core::widget::Operation< - iced_core::widget::OperationOutputWrapper, - >, + operation: &mut dyn iced_core::widget::Operation<()>, ) { - let child_tree = &mut state.children[0]; - let child_layout = layout.children().next().unwrap(); - self.header_bar_inner - .as_widget() - .operate(child_tree, child_layout, renderer, operation); + self.elems_mut() + .zip(&mut state.children) + .zip(layout.children()) + .for_each(|((e, s), l)| { + e.as_widget_mut().operate(s, l, renderer, operation); + }); } fn overlay<'b>( &'b mut self, state: &'b mut tree::Tree, - layout: iced_core::Layout<'_>, + layout: iced_core::Layout<'b>, renderer: &crate::Renderer, + viewport: &iced_core::Rectangle, + translation: Vector, ) -> Option> { - let child_tree = &mut state.children[0]; - let child_layout = layout.children().next().unwrap(); - self.header_bar_inner - .as_widget_mut() - .overlay(child_tree, child_layout, renderer) + self.elems_mut() + .zip(&mut state.children) + .zip(layout.children()) + .find_map(|((e, s), l)| { + e.as_widget_mut() + .overlay(s, l, renderer, viewport, translation) + }) } fn drag_destinations( @@ -250,137 +328,132 @@ impl<'a, Message: Clone + 'static> Widget, renderer: &crate::Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { - if let Some((child_tree, child_layout)) = - state.children.iter().zip(layout.children()).next() - { - self.header_bar_inner.as_widget().drag_destinations( - child_tree, - child_layout, - renderer, - dnd_rectangles, - ); - } + self.elems() + .zip(&state.children) + .zip(layout.children()) + .for_each(|((e, s), l)| { + e.as_widget() + .drag_destinations(s, l, renderer, dnd_rectangles); + }); + } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: iced_core::Layout<'_>, + state: &tree::Tree, + p: iced::mouse::Cursor, + ) -> iced_accessibility::A11yTree { + iced_accessibility::A11yTree::join( + self.elems() + .zip(&state.children) + .zip(layout.children()) + .map(|((e, s), l)| e.as_widget().a11y_nodes(l, s, p)), + ) + } +} + +impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { + fn from(w: HeaderBarWidget<'a, Message>) -> Self { + Element::new(w) } } impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { /// Converts the headerbar builder into an Iced element. pub fn view(mut self) -> Element<'a, Message> { + let Spacing { + space_xxxs, + space_xxs, + .. + } = theme::spacing(); + // Take ownership of the regions to be packed. let start = std::mem::take(&mut self.start); let center = std::mem::take(&mut self.center); let mut end = std::mem::take(&mut self.end); // Also packs the window controls at the very end. - end.push(widget::horizontal_space(Length::Fixed(12.0)).into()); - end.push(self.window_controls()); + end.push(self.window_controls(space_xxs)); - let height = match self.density.unwrap_or_else(crate::config::header_size) { - Density::Compact => 40.0, - Density::Spacious => 48.0, - Density::Standard => 48.0, + let padding = if self.is_ssd { + [2, 8, 2, 8] + } else { + match ( + self.density.unwrap_or_else(crate::config::header_size), + self.maximized, // window border handling + ) { + (Density::Compact, true) => [4, 8, 4, 8], + (Density::Compact, false) => [3, 7, 4, 7], + (_, true) => [8, 8, 8, 8], + (_, false) => [7, 7, 8, 7], + } }; - // Creates the headerbar widget. - let mut widget = widget::row::with_capacity(4) - // If elements exist in the start region, append them here. - .push( - widget::row::with_children(start) - .align_items(iced::Alignment::Center) - .apply(widget::container) - .align_x(iced::alignment::Horizontal::Left) - .width(Length::Shrink), - ) - // If elements exist in the center region, use them here. - // This will otherwise use the title as a widget if a title was defined. - .push(if !center.is_empty() { + let start = widget::row::with_children(start) + .spacing(space_xxxs) + .align_y(iced::Alignment::Center) + .into(); + let center = if !center.is_empty() { + Some( widget::row::with_children(center) - .align_items(iced::Alignment::Center) - .apply(widget::container) - .align_x(iced::alignment::Horizontal::Center) - .width(Length::Fill) - .into() - } else if self.title.is_empty() { - widget::horizontal_space(Length::Fill).into() - } else { - self.title_widget() - }) - .push( - widget::row::with_children(end) - .align_items(iced::Alignment::Center) - .apply(widget::container) - .align_x(iced::alignment::Horizontal::Right) - .width(Length::Shrink), + .spacing(space_xxxs) + .align_y(iced::Alignment::Center) + .into(), ) - .align_items(iced::Alignment::Center) - .height(Length::Fixed(height)) - .padding([0, 8]) - .spacing(8) + } else if !self.title.is_empty() { + Some( + widget::text::heading(self.title) + .wrapping(text::Wrapping::None) + .ellipsize(text::Ellipsize::End(text::EllipsizeHeightLimit::Lines(1))) + .into(), + ) + } else { + None + }; + let end = widget::row::with_children(end) + .spacing(space_xxs) + .align_y(iced::Alignment::Center) + .into(); + + let mut widget = HeaderBarWidget::new(start, center, end) .apply(widget::container) - .style(crate::theme::Container::HeaderBar { + .class(theme::Container::HeaderBar { focused: self.focused, + sharp_corners: self.sharp_corners, + transparent: self.transparent, }) - .center_y() + .height(Length::Fixed(32.0 + padding[0] as f32 + padding[2] as f32)) + .padding(padding) .apply(widget::mouse_area); - // Assigns a message to emit when the headerbar is dragged. - if let Some(message) = self.on_drag.clone() { + if let Some(message) = self.on_drag { widget = widget.on_drag(message); } - - // Assigns a message to emit when the headerbar is double-clicked. - if let Some(message) = self.on_maximize.clone() { + if let Some(message) = self.on_maximize { widget = widget.on_release(message); } - if let Some(message) = self.on_double_click.clone() { + if let Some(message) = self.on_double_click { widget = widget.on_double_press(message); } - if let Some(message) = self.on_right_click.clone() { + if let Some(message) = self.on_right_click { widget = widget.on_right_press(message); } widget.into() } - fn title_widget(&mut self) -> Element<'a, Message> { - let mut title = Cow::default(); - std::mem::swap(&mut title, &mut self.title); - - widget::text::heading(title) - .apply(widget::container) - .center_x() - .center_y() - .width(Length::Fill) - .height(Length::Fill) - .into() - } - /// Creates the widget for window controls. - fn window_controls(&mut self) -> Element<'a, Message> { + fn window_controls(&mut self, spacing: u16) -> Element<'a, Message> { macro_rules! icon { ($name:expr, $size:expr, $on_press:expr) => {{ - #[cfg(target_os = "linux")] - let icon = { - widget::icon::from_name($name) - .apply(widget::button::icon) - .padding(8) - }; - - #[cfg(not(target_os = "linux"))] - let icon = { - widget::icon::from_svg_bytes(include_bytes!(concat!( - "../../res/icons/", - $name, - ".svg" - ))) - .symbolic(true) + widget::icon::from_name($name) .apply(widget::button::icon) .padding(8) - }; - - icon.style(crate::theme::Button::HeaderBar) + .class(theme::Button::HeaderBar) .selected(self.focused) .icon_size($size) .on_press($on_press) @@ -391,34 +464,28 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { .push_maybe( self.on_minimize .take() - .map(|m: Message| icon!("window-minimize-symbolic", 16, m)), - ) - .push_maybe( - self.on_maximize - .take() - .map(|m| icon!("window-maximize-symbolic", 16, m)), + .map(|m| icon!("window-minimize-symbolic", 16, m)), ) + .push_maybe(self.on_maximize.take().map(|m| { + if self.maximized { + icon!("window-restore-symbolic", 16, m) + } else { + icon!("window-maximize-symbolic", 16, m) + } + })) .push_maybe( self.on_close .take() .map(|m| icon!("window-close-symbolic", 16, m)), ) - .spacing(8) - .apply(widget::container) - .height(Length::Fill) - .center_y() + .spacing(spacing) + .align_y(iced::Alignment::Center) .into() } } impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { fn from(headerbar: HeaderBar<'a, Message>) -> Self { - Element::new(headerbar.build()) - } -} - -impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { - fn from(headerbar: HeaderBarWidget<'a, Message>) -> Self { - Element::new(headerbar) + headerbar.view() } } diff --git a/src/widget/icon/bundle.rs b/src/widget/icon/bundle.rs new file mode 100644 index 00000000..bb6ce244 --- /dev/null +++ b/src/widget/icon/bundle.rs @@ -0,0 +1,21 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Embedded icons for platforms which do not support icon themes yet. + +/// Icon bundling is not enabled on unix platforms. +#[cfg(all(unix, not(target_os = "macos")))] +pub fn get(icon_name: &str) -> Option { + None +} + +#[cfg(any(not(unix), target_os = "macos"))] +/// Get a bundled icon on non-unix platforms. +pub fn get(icon_name: &str) -> Option { + ICONS + .get(icon_name) + .map(|bytes| super::Data::Svg(crate::iced::widget::svg::Handle::from_memory(*bytes))) +} + +#[cfg(any(not(unix), target_os = "macos"))] +include!(concat!(env!("OUT_DIR"), "/bundled_icons.rs")); diff --git a/src/widget/icon/handle.rs b/src/widget/icon/handle.rs index 1a138847..7e0bab02 100644 --- a/src/widget/icon/handle.rs +++ b/src/widget/icon/handle.rs @@ -1,7 +1,7 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use super::{Icon, Named}; +use super::Icon; use crate::widget::{image, svg}; use std::borrow::Cow; use std::ffi::OsStr; @@ -17,6 +17,7 @@ pub struct Handle { } impl Handle { + #[inline] pub fn icon(self) -> Icon { super::icon(self) } @@ -25,7 +26,7 @@ impl Handle { #[must_use] #[derive(Clone, Debug, Hash)] pub enum Data { - Name(Named), + // Name(Named), Image(image::Handle), Svg(svg::Handle), } @@ -48,15 +49,22 @@ pub fn from_path(path: PathBuf) -> Handle { /// Create an image handle from memory. pub fn from_raster_bytes( bytes: impl Into> - + std::convert::AsRef<[u8]> - + std::marker::Send - + std::marker::Sync - + 'static, + + std::convert::AsRef<[u8]> + + std::marker::Send + + std::marker::Sync + + 'static, ) -> Handle { - Handle { - symbolic: false, - data: Data::Image(image::Handle::from_memory(bytes)), + fn inner(bytes: Cow<'static, [u8]>) -> Handle { + Handle { + symbolic: false, + data: match bytes { + Cow::Owned(b) => Data::Image(image::Handle::from_bytes(b)), + Cow::Borrowed(b) => Data::Image(image::Handle::from_bytes(b)), + }, + } } + + inner(bytes.into()) } /// Create an image handle from RGBA data, where you must define the width and height. @@ -64,15 +72,23 @@ pub fn from_raster_pixels( width: u32, height: u32, pixels: impl Into> - + std::convert::AsRef<[u8]> - + std::marker::Send - + std::marker::Sync - + 'static, + + std::convert::AsRef<[u8]> + + std::marker::Send + + std::marker::Sync, ) -> Handle { - Handle { - symbolic: false, - data: Data::Image(image::Handle::from_pixels(width, height, pixels)), + fn inner(width: u32, height: u32, pixels: Cow<'static, [u8]>) -> Handle { + Handle { + symbolic: false, + data: match pixels { + Cow::Owned(pixels) => Data::Image(image::Handle::from_rgba(width, height, pixels)), + Cow::Borrowed(pixels) => { + Data::Image(image::Handle::from_rgba(width, height, pixels)) + } + }, + } } + + inner(width, height, pixels.into()) } /// Create a SVG handle from memory. diff --git a/src/widget/icon/mod.rs b/src/widget/icon/mod.rs index 752125fe..031b4b0c 100644 --- a/src/widget/icon/mod.rs +++ b/src/widget/icon/mod.rs @@ -3,19 +3,20 @@ //! Lazily-generated SVG icon widget for Iced. +mod bundle; mod named; -use std::ffi::OsStr; use std::sync::Arc; pub use named::{IconFallback, Named}; mod handle; -pub use handle::{from_path, from_raster_bytes, from_raster_pixels, from_svg_bytes, Data, Handle}; +pub use handle::{Data, Handle, from_path, from_raster_bytes, from_raster_pixels, from_svg_bytes}; use crate::Element; use derive_setters::Setters; use iced::widget::{Image, Svg}; -use iced::{ContentFit, Length}; +use iced::{ContentFit, Length, Radians, Rectangle}; +use iced_core::Rotation; /// Create an [`Icon`] from a pre-existing [`Handle`] pub fn icon(handle: Handle) -> Icon { @@ -24,7 +25,8 @@ pub fn icon(handle: Handle) -> Icon { handle, height: None, size: 16, - style: crate::theme::Svg::default(), + class: crate::theme::Svg::default(), + rotation: None, width: None, } } @@ -40,27 +42,22 @@ pub fn from_name(name: impl Into>) -> Named { pub struct Icon { #[setters(skip)] handle: Handle, - style: crate::theme::Svg, + class: crate::theme::Svg, + #[setters(skip)] pub(super) size: u16, content_fit: ContentFit, #[setters(strip_option)] width: Option, #[setters(strip_option)] height: Option, + #[setters(strip_option)] + rotation: Option, } impl Icon { #[must_use] pub fn into_svg_handle(self) -> Option { match self.handle.data { - Data::Name(named) => { - if let Some(path) = named.path() { - if path.extension().is_some_and(|ext| ext == OsStr::new("svg")) { - return Some(iced_core::svg::Handle::from_path(path)); - } - } - } - Data::Image(_) => (), Data::Svg(handle) => return Some(handle), } @@ -68,6 +65,12 @@ impl Icon { None } + #[must_use] + pub fn size(mut self, size: u16) -> Self { + self.size = size; + self + } + #[must_use] fn view<'a, Message: 'a>(self) -> Element<'a, Message> { let from_image = |handle| { @@ -80,13 +83,14 @@ impl Icon { self.height .unwrap_or_else(|| Length::Fixed(f32::from(self.size))), ) + .rotation(self.rotation.unwrap_or_default()) .content_fit(self.content_fit) .into() }; let from_svg = |handle| { Svg::::new(handle) - .style(self.style.clone()) + .class(self.class.clone()) .width( self.width .unwrap_or_else(|| Length::Fixed(f32::from(self.size))), @@ -95,25 +99,13 @@ impl Icon { self.height .unwrap_or_else(|| Length::Fixed(f32::from(self.size))), ) + .rotation(self.rotation.unwrap_or_default()) .content_fit(self.content_fit) .symbolic(self.handle.symbolic) .into() }; match self.handle.data { - Data::Name(named) => { - if let Some(path) = named.path() { - if path.extension().is_some_and(|ext| ext == OsStr::new("svg")) { - from_svg(iced_core::svg::Handle::from_path(path)) - } else { - from_image(iced_core::image::Handle::from_path(path)) - } - } else { - let bytes: &'static [u8] = &[]; - from_svg(iced_core::svg::Handle::from_memory(bytes)) - } - } - Data::Image(handle) => from_image(handle), Data::Svg(handle) => from_svg(handle), } @@ -125,3 +117,31 @@ impl<'a, Message: 'a> From for Element<'a, Message> { icon.view::() } } + +/// Draw an icon in the given bounds via the runtime's renderer. +pub fn draw(renderer: &mut crate::Renderer, handle: &Handle, icon_bounds: Rectangle) { + match handle.clone().data { + Data::Svg(handle) => iced_core::svg::Renderer::draw_svg( + renderer, + iced_core::svg::Svg::new(handle), + icon_bounds, + icon_bounds, + ), + + Data::Image(handle) => { + iced_core::image::Renderer::draw_image( + renderer, + iced_core::Image { + handle, + filter_method: iced_core::image::FilterMethod::Linear, + rotation: Radians(0.), + border_radius: [0.0; 4].into(), + opacity: 1.0, + snap: true, + }, + icon_bounds, + icon_bounds, + ); + } + } +} diff --git a/src/widget/icon/named.rs b/src/widget/icon/named.rs index 40432c04..dfd66cf5 100644 --- a/src/widget/icon/named.rs +++ b/src/widget/icon/named.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 use super::{Handle, Icon}; -use std::{borrow::Cow, path::PathBuf, sync::Arc}; +use std::{borrow::Cow, ffi::OsStr, path::PathBuf, sync::Arc}; #[derive(Debug, Clone, Default, Hash)] /// Fallback icon to use if the icon was not found. @@ -41,17 +41,18 @@ pub struct Named { impl Named { pub fn new(name: impl Into>) -> Self { let name = name.into(); + let symbolic = name.ends_with("-symbolic"); Self { - symbolic: name.ends_with("-symbolic"), + symbolic, name, fallback: Some(IconFallback::Default), size: None, scale: None, - prefer_svg: false, + prefer_svg: symbolic, } } - #[cfg(not(windows))] + #[cfg(all(unix, not(target_os = "macos")))] #[must_use] pub fn path(self) -> Option { let name = &*self.name; @@ -106,20 +107,34 @@ impl Named { result } - #[cfg(windows)] + #[cfg(any(not(unix), target_os = "macos"))] #[must_use] pub fn path(self) -> Option { //TODO: implement icon lookup for Windows None } + #[inline] pub fn handle(self) -> Handle { + let name = self.name.clone(); Handle { symbolic: self.symbolic, - data: super::Data::Name(self), + data: if let Some(path) = self.path() { + if path.extension().is_some_and(|ext| ext == OsStr::new("svg")) { + super::Data::Svg(iced_core::svg::Handle::from_path(path)) + } else { + super::Data::Image(iced_core::image::Handle::from_path(path)) + } + } else { + super::bundle::get(&name).unwrap_or_else(|| { + let bytes: &'static [u8] = &[]; + super::Data::Svg(iced_core::svg::Handle::from_memory(bytes)) + }) + }, } } + #[inline] pub fn icon(self) -> Icon { let size = self.size; @@ -133,18 +148,21 @@ impl Named { } impl From for Handle { + #[inline] fn from(builder: Named) -> Self { builder.handle() } } impl From for Icon { + #[inline] fn from(builder: Named) -> Self { builder.icon() } } -impl<'a, Message: 'static> From for crate::Element<'a, Message> { +impl From for crate::Element<'_, Message> { + #[inline] fn from(builder: Named) -> Self { builder.icon().into() } diff --git a/src/widget/id_container.rs b/src/widget/id_container.rs index 2bd56b39..716ee138 100644 --- a/src/widget/id_container.rs +++ b/src/widget/id_container.rs @@ -3,9 +3,9 @@ use iced_core::layout; use iced_core::mouse; use iced_core::overlay; use iced_core::renderer; -use iced_core::widget::{Id, Tree}; -use iced_core::{Clipboard, Element, Layout, Length, Rectangle, Shell, Widget}; -pub use iced_style::container::{Appearance, StyleSheet}; +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 id_container<'a, Message: 'static, Theme, E>( content: E, @@ -13,8 +13,8 @@ pub fn id_container<'a, Message: 'static, Theme, E>( ) -> IdContainer<'a, Message, Theme, crate::Renderer> where E: Into>, - Theme: iced_style::container::StyleSheet, - ::Style: From, + Theme: iced_widget::container::Catalog, + ::Class<'a>: From>, { IdContainer::new(content, id) } @@ -47,8 +47,8 @@ where } } -impl<'a, Message, Theme, Renderer> Widget - for IdContainer<'a, Message, Theme, Renderer> +impl Widget + for IdContainer<'_, Message, Theme, Renderer> where Renderer: iced_core::Renderer, { @@ -57,7 +57,7 @@ where } fn diff(&mut self, tree: &mut Tree) { - tree.children[0].diff(&mut self.content); + tree.diff_children(std::slice::from_mut(&mut self.content)); } fn size(&self) -> iced_core::Size { @@ -65,59 +65,66 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { let node = self .content - .as_widget() + .as_widget_mut() .layout(&mut tree.children[0], renderer, limits); let size = node.size(); layout::Node::with_children(size, vec![node]) } 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 Operation, ) { - operation.container(Some(&self.id), layout.bounds(), &mut |operation| { - self.content.as_widget().operate( + 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(), + layout + .children() + .next() + .unwrap() + .with_virtual_offset(layout.virtual_offset()), 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.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_position, renderer, clipboard, shell, viewport, - ) + ); } fn mouse_interaction( @@ -131,7 +138,7 @@ where let content_layout = layout.children().next().unwrap(); self.content.as_widget().mouse_interaction( &tree.children[0], - content_layout, + content_layout.with_virtual_offset(layout.virtual_offset()), cursor_position, viewport, renderer, @@ -154,7 +161,7 @@ where renderer, theme, renderer_style, - content_layout, + content_layout.with_virtual_offset(layout.virtual_offset()), cursor_position, viewport, ); @@ -163,13 +170,21 @@ where fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { 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, ) } @@ -178,12 +193,12 @@ where state: &Tree, layout: Layout<'_>, renderer: &Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + 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, + content_layout.with_virtual_offset(layout.virtual_offset()), renderer, dnd_rectangles, ); @@ -196,6 +211,23 @@ where 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> diff --git a/src/widget/layer_container.rs b/src/widget/layer_container.rs index 7033e342..110af518 100644 --- a/src/widget/layer_container.rs +++ b/src/widget/layer_container.rs @@ -1,22 +1,22 @@ +use crate::Theme; use cosmic_theme::LayeredTheme; use iced::widget::Container; -use iced_core::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::Tree; -use iced_core::{Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Widget}; -pub use iced_style::container::{Appearance, StyleSheet}; +use iced_core::{ + Alignment, Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Vector, Widget, +}; +pub use iced_widget::container::{Catalog, Style}; -pub fn layer_container<'a, Message: 'static, Theme, E>( +pub fn layer_container<'a, Message: 'static, E>( content: E, -) -> LayerContainer<'a, Message, Theme, crate::Renderer> +) -> LayerContainer<'a, Message, crate::Renderer> where E: Into>, - Theme: iced_style::container::StyleSheet + LayeredTheme, - ::Style: From, { LayerContainer::new(content) } @@ -25,20 +25,18 @@ where /// /// It is normally used for alignment purposes. #[allow(missing_debug_implementations)] -pub struct LayerContainer<'a, Message, Theme, Renderer> +pub struct LayerContainer<'a, Message, Renderer> where Renderer: iced_core::Renderer, - Theme: iced_style::container::StyleSheet + LayeredTheme, { layer: Option, container: Container<'a, Message, Theme, Renderer>, } -impl<'a, Message, Theme, Renderer> LayerContainer<'a, Message, Theme, Renderer> +impl<'a, Message, Renderer> LayerContainer<'a, Message, Renderer> where Renderer: iced_core::Renderer, - Theme: iced_style::container::StyleSheet + LayeredTheme, - ::Style: From, + // iced_widget::container::Style: From, { /// Creates an empty [`Container`]. pub(crate) fn new(content: T) -> Self @@ -55,7 +53,7 @@ where #[must_use] pub fn layer(mut self, layer: cosmic_theme::Layer) -> Self { self.layer = Some(layer); - self.style(match layer { + self.class(match layer { cosmic_theme::Layer::Background => crate::theme::Container::Background, cosmic_theme::Layer::Primary => crate::theme::Container::Primary, cosmic_theme::Layer::Secondary => crate::theme::Container::Secondary, @@ -71,6 +69,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 @@ -78,6 +77,7 @@ where /// Sets the height of the [`LayerContainer`]. #[must_use] + #[inline] pub fn height(mut self, height: Length) -> Self { self.container = self.container.height(height); self @@ -85,6 +85,7 @@ where /// Sets the maximum width of the [`LayerContainer`]. #[must_use] + #[inline] pub fn max_width(mut self, max_width: f32) -> Self { self.container = self.container.max_width(max_width); self @@ -92,6 +93,7 @@ where /// Sets the maximum height of the [`LayerContainer`] in pixels. #[must_use] + #[inline] pub fn max_height(mut self, max_height: f32) -> Self { self.container = self.container.max_height(max_height); self @@ -99,45 +101,55 @@ where /// Sets the content alignment for the horizontal axis of the [`LayerContainer`]. #[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 [`LayerContainer`]. #[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 [`LayerContainer`]. #[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 [`LayerContainer`]. #[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 [`LayerContainer`]. #[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, Theme, Renderer> Widget - for LayerContainer<'a, Message, Theme, Renderer> +impl Widget for LayerContainer<'_, Message, Renderer> where Renderer: iced_core::Renderer, - Theme: iced_style::container::StyleSheet + LayeredTheme + Clone, { fn children(&self) -> Vec { self.container.children() @@ -160,7 +172,7 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, @@ -169,29 +181,27 @@ where } 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, @@ -232,6 +242,7 @@ where } else { theme.clone() }; + self.container.draw( tree, renderer, @@ -246,10 +257,13 @@ where fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - self.container.overlay(tree, layout, renderer) + self.container + .overlay(tree, layout, renderer, viewport, translation) } fn drag_destinations( @@ -257,7 +271,7 @@ where state: &Tree, layout: Layout<'_>, renderer: &Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { self.container .drag_destinations(state, layout, renderer, dnd_rectangles); @@ -270,17 +284,27 @@ where fn set_id(&mut self, id: crate::widget::Id) { self.container.set_id(id); } + + #[cfg(feature = "a11y")] + /// get the a11y nodes for the widget + fn a11y_nodes( + &self, + layout: iced_core::Layout<'_>, + state: &Tree, + p: iced::mouse::Cursor, + ) -> iced_accessibility::A11yTree { + self.container.a11y_nodes(layout, state, p) + } } -impl<'a, Message, Theme, Renderer> From> +impl<'a, Message, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: 'a, Renderer: 'a + iced_core::Renderer, - Theme: iced_style::container::StyleSheet + LayeredTheme + 'a + Clone, { fn from( - column: LayerContainer<'a, Message, Theme, Renderer>, + column: LayerContainer<'a, Message, Renderer>, ) -> Element<'a, Message, Theme, Renderer> { Element::new(column) } diff --git a/src/widget/list/column.rs b/src/widget/list/column.rs deleted file mode 100644 index 082fc8df..00000000 --- a/src/widget/list/column.rs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2022 System76 -// SPDX-License-Identifier: MPL-2.0 - -use iced_core::Padding; -use iced_style::container::StyleSheet; - -use crate::{theme, widget::divider, Apply, Element}; - -pub fn list_column<'a, Message: 'static>() -> ListColumn<'a, Message> { - ListColumn::default() -} - -#[must_use] -pub struct ListColumn<'a, Message> { - spacing: u16, - padding: Padding, - style: ::Style, - children: Vec>, -} - -impl<'a, Message: 'static> Default for ListColumn<'a, Message> { - fn default() -> Self { - Self { - spacing: theme::THEME.lock().unwrap().cosmic().spacing.space_xxs, - padding: Padding::from(0), - style: ::Style::List, - children: Vec::with_capacity(4), - } - } -} - -impl<'a, Message: 'static> ListColumn<'a, Message> { - pub fn new() -> Self { - Self::default() - } - - #[allow(clippy::should_implement_trait)] - pub fn add(mut self, item: impl Into>) -> Self { - if !self.children.is_empty() { - self.children.push(divider::horizontal::light().into()); - } - - // Ensure a minimum height of 32. - let container = crate::widget::container(item) - .min_height(32) - .align_y(iced::alignment::Vertical::Center); - - self.children.push(container.into()); - self - } - - pub fn spacing(mut self, spacing: u16) -> Self { - self.spacing = spacing; - self - } - - /// Sets the style variant of this [`Circular`]. - pub fn style(mut self, style: ::Style) -> Self { - self.style = style; - self - } - - pub fn padding(mut self, padding: impl Into) -> Self { - self.padding = padding.into(); - self - } - - #[must_use] - pub fn into_element(self) -> Element<'a, Message> { - crate::widget::column::with_children(self.children) - .spacing(self.spacing) - .padding(self.padding) - .apply(super::container) - .padding([self.spacing, 8]) - .style(self.style) - .into() - } -} - -impl<'a, Message: 'static> From> for Element<'a, Message> { - fn from(column: ListColumn<'a, Message>) -> Self { - column.into_element() - } -} diff --git a/src/widget/list/list_column.rs b/src/widget/list/list_column.rs new file mode 100644 index 00000000..4ef3fc01 --- /dev/null +++ b/src/widget/list/list_column.rs @@ -0,0 +1,213 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::widget::container::Catalog; +use crate::widget::{button, column, container, divider, row, space::vertical}; +use crate::{Apply, Element, theme}; +use iced::{Length, Padding}; + +/// A button list item for use in a [`ListColumn`]. +pub struct ListButton<'a, Message> { + content: Element<'a, Message>, + on_press: Option, + selected: bool, +} + +/// Creates a [`ListButton`] with the given content. +pub fn button<'a, Message>(content: impl Into>) -> ListButton<'a, Message> { + ListButton { + content: content.into(), + on_press: None, + selected: false, + } +} + +impl<'a, Message: 'static> ListButton<'a, Message> { + pub fn on_press(mut self, on_press: Message) -> Self { + self.on_press = Some(on_press); + self + } + + pub fn on_press_maybe(mut self, on_press: Option) -> Self { + self.on_press = on_press; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } +} + +pub enum ListItem<'a, Message> { + Element(Element<'a, Message>), + Button(ListButton<'a, Message>), +} + +/// A trait for types that can be added to a [`ListColumn`]. +pub trait IntoListItem<'a, Message> { + fn into_list_item(self) -> ListItem<'a, Message>; +} + +impl<'a, Message, T> IntoListItem<'a, Message> for T +where + T: Into>, +{ + fn into_list_item(self) -> ListItem<'a, Message> { + ListItem::Element(self.into()) + } +} + +impl<'a, Message> IntoListItem<'a, Message> for ListButton<'a, Message> { + fn into_list_item(self) -> ListItem<'a, Message> { + ListItem::Button(self) + } +} + +// Snapshots the padding values at the moment an item is added +struct ListEntry<'a, Message> { + item: ListItem<'a, Message>, + item_padding: Padding, + divider_padding: u16, +} + +#[must_use] +pub struct ListColumn<'a, Message> { + list_item_padding: Padding, + divider_padding: u16, + style: theme::Container<'a>, + children: Vec>, +} + +#[inline] +pub fn list_column<'a, Message: 'static>() -> ListColumn<'a, Message> { + ListColumn::default() +} + +pub fn with_capacity<'a, Message: 'static>(capacity: usize) -> ListColumn<'a, Message> { + let cosmic_theme::Spacing { + space_xxs, space_m, .. + } = theme::spacing(); + + ListColumn { + list_item_padding: [space_xxs, space_m].into(), + divider_padding: 0, + style: theme::Container::List, + children: Vec::with_capacity(capacity), + } +} + +impl Default for ListColumn<'_, Message> { + fn default() -> Self { + with_capacity(4) + } +} + +impl<'a, Message: Clone + 'static> ListColumn<'a, Message> { + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Adds a [`ListItem`] to the [`ListColumn`]. + #[allow(clippy::should_implement_trait)] + pub fn add(mut self, item: impl IntoListItem<'a, Message>) -> Self { + self.children.push(ListEntry { + item: item.into_list_item(), + item_padding: self.list_item_padding, + divider_padding: self.divider_padding, + }); + self + } + + /// Sets the style variant of this [`ListColumn`]. + #[inline] + pub fn style(mut self, style: ::Class<'a>) -> Self { + self.style = style; + self + } + + pub fn list_item_padding(mut self, padding: impl Into) -> Self { + self.list_item_padding = padding.into(); + self + } + + #[inline] + pub fn divider_padding(mut self, padding: u16) -> Self { + self.divider_padding = padding; + self + } + + #[must_use] + pub fn into_element(self) -> Element<'a, Message> { + let count = self.children.len(); + let last_index = count.saturating_sub(1); + let radius_s = theme::active().cosmic().radius_s(); + let mut col = column::with_capacity((2 * count).saturating_sub(1)); + + // Ensure minimum height of 32 + let content_row = |content| { + row![container(content), vertical().height(32)].align_y(iced::Alignment::Center) + }; + + for ( + i, + ListEntry { + item, + item_padding, + divider_padding, + }, + ) in self.children.into_iter().enumerate() + { + if i > 0 { + col = col + .push(container(divider::horizontal::default()).padding([0, divider_padding])); + } + + col = match item { + ListItem::Element(content) => col.push( + content_row(content) + .padding(item_padding) + .width(Length::Fill), + ), + ListItem::Button(ListButton { + content, + on_press, + selected, + }) => col.push( + content_row(content) + .apply(button::custom) + .padding(item_padding) + .width(Length::Fill) + .on_press_maybe(on_press) + .selected(selected) + .class(theme::Button::ListItem(get_radius( + radius_s, + i == 0, + i == last_index, + ))), + ), + }; + } + + col.width(Length::Fill) + .apply(container) + .class(self.style) + .into() + } +} + +impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { + fn from(column: ListColumn<'a, Message>) -> Self { + column.into_element() + } +} + +fn get_radius(radius: [f32; 4], first: bool, last: bool) -> [f32; 4] { + match (first, last) { + (true, true) => radius, + (true, false) => [radius[0], radius[1], 0.0, 0.0], + (false, true) => [0.0, 0.0, radius[2], radius[3]], + (false, false) => [0.0, 0.0, 0.0, 0.0], + } +} diff --git a/src/widget/list/mod.rs b/src/widget/list/mod.rs index d94da271..71eda086 100644 --- a/src/widget/list/mod.rs +++ b/src/widget/list/mod.rs @@ -1,18 +1,6 @@ // Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 -pub mod column; +pub mod list_column; -pub use self::column::{list_column, ListColumn}; - -use crate::widget::Container; -use crate::Element; - -pub fn container<'a, Message>( - content: impl Into>, -) -> Container<'a, Message, crate::Theme, crate::Renderer> { - super::container(content) - .padding([16, 6]) - .style(crate::theme::Container::List) - .width(iced::Length::Fill) -} +pub use self::list_column::{ListButton, ListColumn, button, list_column}; diff --git a/src/widget/menu.rs b/src/widget/menu.rs index d5ea3222..9d4ce4b1 100644 --- a/src/widget/menu.rs +++ b/src/widget/menu.rs @@ -55,6 +55,7 @@ //! pub mod action; + pub use action::MenuAction as Action; mod flex; @@ -63,15 +64,15 @@ pub use key_bind::KeyBind; mod menu_bar; pub(crate) use menu_bar::MenuBarState; -pub use menu_bar::{menu_bar as bar, MenuBar}; +pub use menu_bar::{MenuBar, menu_bar as bar}; mod menu_inner; mod menu_tree; pub use menu_tree::{ - menu_button, menu_items as items, menu_root as root, MenuItem as Item, MenuTree as Tree, + MenuItem as Item, MenuTree as Tree, menu_button, menu_items as items, menu_root as root, }; pub use crate::style::menu_bar::{Appearance, StyleSheet}; pub(crate) use menu_bar::{menu_roots_children, menu_roots_diff}; -pub(crate) use menu_inner::Menu; pub use menu_inner::{CloseCondition, ItemHeight, ItemWidth, PathHighlight}; +pub(crate) use menu_inner::{Direction, Menu, init_root_menu}; diff --git a/src/widget/menu/flex.rs b/src/widget/menu/flex.rs index e7930574..4a58f13a 100644 --- a/src/widget/menu/flex.rs +++ b/src/widget/menu/flex.rs @@ -1,11 +1,14 @@ // From iced_aw, license MIT -use iced_core::widget::Tree; +use iced_core::{Widget, widget::Tree}; use iced_widget::core::{ + Alignment, Element, Padding, Point, Size, layout::{Limits, Node}, - renderer, Alignment, Element, Padding, Point, Size, + renderer, }; +use crate::widget::RcElementWrapper; + /// The main axis of a flex layout. #[derive(Debug)] pub enum Axis { @@ -54,11 +57,11 @@ pub fn resolve<'a, E, Message, Renderer>( padding: Padding, spacing: f32, align_items: Alignment, - items: &[E], + items: &mut [E], tree: &mut [&mut Tree], ) -> Node where - E: std::borrow::Borrow>, + E: std::borrow::BorrowMut>, Renderer: renderer::Renderer, { let limits = limits.shrink(padding); @@ -66,7 +69,7 @@ where let max_cross = axis.cross(limits.max()); let mut fill_sum = 0; - let mut cross = axis.cross(limits.min()).max(axis.cross(Size::INFINITY)); + let mut cross = axis.cross(limits.min()).max(axis.cross(Size::INFINITE)); let mut available = axis.main(limits.max()) - total_spacing; let mut nodes: Vec = Vec::with_capacity(items.len()); @@ -75,8 +78,8 @@ where if align_items == Alignment::Center { let mut fill_cross = axis.cross(limits.min()); - for (child, tree) in items.iter().zip(tree.iter_mut()) { - let child = child.borrow(); + for (child, tree) in items.iter_mut().zip(tree.iter_mut()) { + let child = child.borrow_mut(); let c_size = child.as_widget().size(); let cross_fill_factor = match axis { Axis::Horizontal => c_size.height, @@ -89,7 +92,7 @@ where let child_limits = Limits::new(Size::ZERO, Size::new(max_width, max_height)); - let layout = child.as_widget().layout(tree, renderer, &child_limits); + let layout = child.as_widget_mut().layout(tree, renderer, &child_limits); let size = layout.size(); fill_cross = fill_cross.max(axis.cross(size)); @@ -99,8 +102,8 @@ where cross = fill_cross; } - for (i, (child, tree)) in items.iter().zip(tree.iter_mut()).enumerate() { - let child = child.borrow(); + for (i, (child, tree)) in items.iter_mut().zip(tree.iter_mut()).enumerate() { + let child = child.borrow_mut(); let c_size = child.as_widget().size(); let fill_factor = match axis { Axis::Horizontal => c_size.width, @@ -126,7 +129,7 @@ where Size::new(max_width, max_height), ); - let layout = child.as_widget().layout(tree, renderer, &child_limits); + let layout = child.as_widget_mut().layout(tree, renderer, &child_limits); let size = layout.size(); available -= axis.main(size); @@ -143,8 +146,8 @@ where let remaining = available.max(0.0); - for (i, (child, tree)) in items.iter().zip(tree.iter_mut()).enumerate() { - let child = child.borrow(); + for (i, (child, tree)) in items.iter_mut().zip(tree.iter_mut()).enumerate() { + let child = child.borrow_mut(); let c_size = child.as_widget().size(); let fill_factor = match axis { Axis::Horizontal => c_size.width, @@ -177,7 +180,7 @@ where Size::new(max_width, max_height), ); - let layout = child.as_widget().layout(tree, renderer, &child_limits); + let layout = child.as_widget_mut().layout(tree, renderer, &child_limits); if align_items != Alignment::Center { cross = cross.max(axis.cross(layout.size())); @@ -197,16 +200,183 @@ where let (x, y) = axis.pack(main, pad.1); - let node_ = node.clone().move_to(Point::new(x, y)); + node.move_to_mut(Point::new(x, y)); - let node_ = match axis { - Axis::Horizontal => node_.align(Alignment::Start, align_items, Size::new(0.0, cross)), - Axis::Vertical => node_.align(align_items, Alignment::Start, Size::new(cross, 0.0)), + match axis { + Axis::Horizontal => { + node.align_mut(Alignment::Start, align_items, Size::new(0.0, cross)) + } + Axis::Vertical => node.align_mut(align_items, Alignment::Start, Size::new(cross, 0.0)), }; - let size = node_.bounds().size(); - - *node = node_; + let size = node.bounds().size(); + + main += axis.main(size); + } + + let (width, height) = axis.pack(main - pad.0, cross); + let size = limits.resolve(width, height, Size::new(width, height)); + + Node::with_children(size.expand(padding), nodes) +} + +/// Computes the flex layout with the given axis and limits, applying spacing, +/// padding and alignment to the items as needed. +/// +/// It returns a new layout [`Node`]. +pub fn resolve_wrapper<'a, Message>( + axis: &Axis, + renderer: &crate::Renderer, + limits: &Limits, + padding: Padding, + spacing: f32, + align_items: Alignment, + items: &mut [&mut RcElementWrapper], + tree: &mut [&mut Tree], +) -> Node { + let limits = limits.shrink(padding); + let total_spacing = spacing * items.len().saturating_sub(1) as f32; + let max_cross = axis.cross(limits.max()); + + let mut fill_sum = 0; + let mut cross = axis.cross(limits.min()).max(axis.cross(Size::INFINITE)); + let mut available = axis.main(limits.max()) - total_spacing; + + let mut nodes: Vec = Vec::with_capacity(items.len()); + nodes.resize(items.len(), Node::default()); + + if align_items == Alignment::Center { + let mut fill_cross = axis.cross(limits.min()); + + for (child, tree) in items.into_iter().zip(tree.iter_mut()) { + let c_size = child.size(); + let cross_fill_factor = match axis { + Axis::Horizontal => c_size.height, + Axis::Vertical => c_size.width, + } + .fill_factor(); + + if cross_fill_factor == 0 { + let (max_width, max_height) = axis.pack(available, max_cross); + + let child_limits = Limits::new(Size::ZERO, Size::new(max_width, max_height)); + + let layout = child.layout(tree, renderer, &child_limits); + let size = layout.size(); + + fill_cross = fill_cross.max(axis.cross(size)); + } + } + + cross = fill_cross; + } + + for (i, (child, tree)) in items.into_iter().zip(tree.iter_mut()).enumerate() { + let c_size = child.size(); + let fill_factor = match axis { + Axis::Horizontal => c_size.width, + Axis::Vertical => c_size.height, + } + .fill_factor(); + + if fill_factor == 0 { + let (min_width, min_height) = if align_items == Alignment::Center { + axis.pack(0.0, cross) + } else { + axis.pack(0.0, 0.0) + }; + + let (max_width, max_height) = if align_items == Alignment::Center { + axis.pack(available, cross) + } else { + axis.pack(available, max_cross) + }; + + let child_limits = Limits::new( + Size::new(min_width, min_height), + Size::new(max_width, max_height), + ); + + let layout = child.layout(tree, renderer, &child_limits); + let size = layout.size(); + + available -= axis.main(size); + + if align_items != Alignment::Center { + cross = cross.max(axis.cross(size)); + } + + nodes[i] = layout; + } else { + fill_sum += fill_factor; + } + } + + let remaining = available.max(0.0); + + for (i, (child, tree)) in items.into_iter().zip(tree.iter_mut()).enumerate() { + let c_size = child.size(); + let fill_factor = match axis { + Axis::Horizontal => c_size.width, + Axis::Vertical => c_size.height, + } + .fill_factor(); + + if fill_factor != 0 { + let max_main = remaining * f32::from(fill_factor) / f32::from(fill_sum); + let min_main = if max_main.is_infinite() { + 0.0 + } else { + max_main + }; + + let (min_width, min_height) = if align_items == Alignment::Center { + axis.pack(min_main, cross) + } else { + axis.pack(min_main, axis.cross(limits.min())) + }; + + let (max_width, max_height) = if align_items == Alignment::Center { + axis.pack(max_main, cross) + } else { + axis.pack(max_main, max_cross) + }; + + let child_limits = Limits::new( + Size::new(min_width, min_height), + Size::new(max_width, max_height), + ); + + let layout = child.layout(tree, renderer, &child_limits); + + if align_items != Alignment::Center { + cross = cross.max(axis.cross(layout.size())); + } + + nodes[i] = layout; + } + } + + let pad = axis.pack(padding.left, padding.top); + let mut main = pad.0; + + for (i, node) in nodes.iter_mut().enumerate() { + if i > 0 { + main += spacing; + } + + let (x, y) = axis.pack(main, pad.1); + + node.move_to_mut(Point::new(x, y)); + + match axis { + Axis::Horizontal => { + node.align_mut(Alignment::Start, align_items, Size::new(0.0, cross)) + } + Axis::Vertical => node.align_mut(align_items, Alignment::Start, Size::new(cross, 0.0)), + }; + + let size = node.bounds().size(); main += axis.main(size); } diff --git a/src/widget/menu/key_bind.rs b/src/widget/menu/key_bind.rs index a6934ff1..8b4ed227 100644 --- a/src/widget/menu/key_bind.rs +++ b/src/widget/menu/key_bind.rs @@ -40,7 +40,7 @@ impl KeyBind { pub fn matches(&self, modifiers: Modifiers, key: &Key) -> bool { let key_eq = match (key, &self.key) { // CapsLock and Shift change the case of Key::Character, so we compare these in a case insensitive way - (Key::Character(a), Key::Character(b)) => a.eq_ignore_ascii_case(&b), + (Key::Character(a), Key::Character(b)) => a.eq_ignore_ascii_case(b), (a, b) => a.eq(b), }; key_eq diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index 257ccbfb..981446e8 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -1,73 +1,106 @@ // From iced_aw, license MIT //! A widget that handles menu trees +use std::{collections::HashMap, sync::Arc}; + use super::{ menu_inner::{ CloseCondition, Direction, ItemHeight, ItemWidth, Menu, MenuState, PathHighlight, }, menu_tree::MenuTree, }; -use crate::style::menu_bar::StyleSheet; +#[cfg(all( + feature = "multi-window", + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" +))] +use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem}; +use crate::{ + Renderer, + style::menu_bar::StyleSheet, + widget::{ + RcWrapper, + dropdown::menu::{self, State}, + menu::menu_inner::init_root_menu, + }, +}; +use iced::{Point, Shadow, Vector, event::Status, window}; use iced_core::Border; use iced_widget::core::{ - event, + Alignment, Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Widget, event, layout::{Limits, Node}, mouse::{self, Cursor}, - overlay, renderer, touch, - widget::{tree, Tree}, - Alignment, Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Widget, + overlay, + renderer::{self, Renderer as IcedRenderer}, + touch, + widget::{Tree, tree}, }; /// A `MenuBar` collects `MenuTree`s and handles all the layout, event processing, and drawing. -pub fn menu_bar( - menu_roots: Vec>, -) -> MenuBar { +pub fn menu_bar(menu_roots: Vec>) -> MenuBar +where + Message: Clone + 'static, +{ MenuBar::new(menu_roots) } +#[derive(Clone, Default)] pub(crate) struct MenuBarState { + pub(crate) inner: RcWrapper, +} + +pub(crate) struct MenuBarStateInner { + pub(crate) tree: Tree, + pub(crate) popup_id: HashMap, pub(crate) pressed: bool, + pub(crate) bar_pressed: bool, pub(crate) view_cursor: Cursor, pub(crate) open: bool, - pub(crate) active_root: Option, + pub(crate) active_root: Vec, pub(crate) horizontal_direction: Direction, pub(crate) vertical_direction: Direction, + /// List of all menu states pub(crate) menu_states: Vec, } -impl MenuBarState { - pub(super) fn get_trimmed_indices(&self) -> impl Iterator + '_ { +impl MenuBarStateInner { + /// get the list of indices hovered for the menu + pub(super) fn get_trimmed_indices(&self, index: usize) -> impl Iterator + '_ { self.menu_states .iter() + .skip(index) .take_while(|ms| ms.index.is_some()) .map(|ms| ms.index.expect("No indices were found in the menu state.")) } - pub(super) fn reset(&mut self) { + pub(crate) fn reset(&mut self) { self.open = false; - self.active_root = None; + self.active_root = Vec::new(); self.menu_states.clear(); } } -impl Default for MenuBarState { +impl Default for MenuBarStateInner { fn default() -> Self { Self { + tree: Tree::empty(), pressed: false, view_cursor: Cursor::Available([-0.5, -0.5].into()), open: false, - active_root: None, + active_root: Vec::new(), horizontal_direction: Direction::Positive, vertical_direction: Direction::Positive, menu_states: Vec::new(), + popup_id: HashMap::new(), + bar_pressed: false, } } } -pub(crate) fn menu_roots_children<'a, Message, Renderer>( - menu_roots: &Vec>, -) -> Vec +pub(crate) fn menu_roots_children(menu_roots: &[MenuTree]) -> Vec where - Renderer: renderer::Renderer, + Message: Clone + 'static, { /* menu bar @@ -85,7 +118,7 @@ where let flat = root .flattern() .iter() - .map(|mt| Tree::new(mt.item.as_widget())) + .map(|mt| Tree::new(mt.item.clone())) .collect(); tree.children = flat; tree @@ -94,11 +127,9 @@ where } #[allow(invalid_reference_casting)] -pub(crate) fn menu_roots_diff<'a, Message, Renderer>( - menu_roots: &mut Vec>, - tree: &mut Tree, -) where - Renderer: renderer::Renderer, +pub(crate) fn menu_roots_diff(menu_roots: &mut [MenuTree], tree: &mut Tree) +where + Message: Clone + 'static, { if tree.children.len() > menu_roots.len() { tree.children.truncate(menu_roots.len()); @@ -112,7 +143,7 @@ pub(crate) fn menu_roots_diff<'a, Message, Renderer>( .flattern() .iter() .map(|mt| { - let widget = mt.item.as_widget(); + let widget = &mt.item; let widget_ptr = widget as *const dyn Widget; let widget_ptr_mut = widget_ptr as *mut dyn Widget; @@ -130,7 +161,7 @@ pub(crate) fn menu_roots_diff<'a, Message, Renderer>( let flat = root .flattern() .iter() - .map(|mt| Tree::new(mt.item.as_widget())) + .map(|mt| Tree::new(mt.item.clone())) .collect(); tree.children = flat; tree @@ -139,12 +170,18 @@ pub(crate) fn menu_roots_diff<'a, Message, Renderer>( } } +pub fn get_mut_or_default(vec: &mut Vec, index: usize) -> &mut T { + if index < vec.len() { + &mut vec[index] + } else { + vec.resize_with(index + 1, T::default); + &mut vec[index] + } +} + /// A `MenuBar` collects `MenuTree`s and handles all the layout, event processing, and drawing. #[allow(missing_debug_implementations)] -pub struct MenuBar<'a, Message, Renderer = crate::Renderer> -where - Renderer: renderer::Renderer, -{ +pub struct MenuBar { width: Length, height: Length, spacing: f32, @@ -156,17 +193,27 @@ where item_width: ItemWidth, item_height: ItemHeight, path_highlight: Option, - menu_roots: Vec>, + menu_roots: Vec>, style: ::Style, + window_id: window::Id, + #[cfg(all( + feature = "multi-window", + feature = "wayland", + feature = "winit", + target_os = "linux" + ))] + positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, + pub(crate) on_surface_action: + Option Message + Send + Sync + 'static>>, } -impl<'a, Message, Renderer> MenuBar<'a, Message, Renderer> +impl MenuBar where - Renderer: renderer::Renderer, + Message: Clone + 'static, { /// Creates a new [`MenuBar`] with the given menu roots #[must_use] - pub fn new(menu_roots: Vec>) -> Self { + pub fn new(menu_roots: Vec>) -> Self { let mut menu_roots = menu_roots; menu_roots.iter_mut().for_each(MenuTree::set_index); @@ -188,6 +235,15 @@ where path_highlight: Some(PathHighlight::MenuActive), menu_roots, style: ::Style::default(), + window_id: window::Id::NONE, + #[cfg(all( + feature = "multi-window", + feature = "wayland", + feature = "winit", + target_os = "linux" + ))] + positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner::default(), + on_surface_action: None, } } @@ -278,18 +334,208 @@ where self.width = width; self } + + #[cfg(all( + feature = "multi-window", + feature = "wayland", + feature = "winit", + target_os = "linux" + ))] + pub fn with_positioner( + mut self, + positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, + ) -> Self { + self.positioner = positioner; + self + } + + #[must_use] + pub fn window_id(mut self, id: window::Id) -> Self { + self.window_id = id; + self + } + + #[must_use] + pub fn window_id_maybe(mut self, id: Option) -> Self { + if let Some(id) = id { + self.window_id = id; + } + self + } + + #[must_use] + pub fn on_surface_action( + mut self, + handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, + ) -> Self { + self.on_surface_action = Some(Arc::new(handler)); + self + } + + #[cfg(all( + feature = "multi-window", + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + #[allow(clippy::too_many_lines)] + fn create_popup( + &mut self, + layout: Layout<'_>, + view_cursor: Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + my_state: &mut MenuBarState, + ) { + if self.window_id != window::Id::NONE && self.on_surface_action.is_some() { + use crate::surface::action::destroy_popup; + use iced_runtime::platform_specific::wayland::popup::{ + SctkPopupSettings, SctkPositioner, + }; + + let surface_action = self.on_surface_action.as_ref().unwrap(); + let old_active_root = my_state + .inner + .with_data(|state| state.active_root.first().copied()); + + // if position is not on menu bar button skip. + let hovered_root = layout + .children() + .position(|lo| view_cursor.is_over(lo.bounds())); + if hovered_root.is_none() + || old_active_root + .zip(hovered_root) + .is_some_and(|r| r.0 == r.1) + { + return; + } + + let (id, root_list) = my_state.inner.with_data_mut(|state| { + if let Some(id) = state.popup_id.get(&self.window_id).copied() { + // close existing popups + state.menu_states.clear(); + state.active_root.clear(); + shell.publish(surface_action(destroy_popup(id))); + state.view_cursor = view_cursor; + (id, layout.children().map(|lo| lo.bounds()).collect()) + } else { + ( + window::Id::unique(), + layout.children().map(|lo| lo.bounds()).collect(), + ) + } + }); + + let mut popup_menu: Menu<'static, _> = Menu { + tree: my_state.clone(), + menu_roots: std::borrow::Cow::Owned(self.menu_roots.clone()), + bounds_expand: self.bounds_expand, + menu_overlays_parent: false, + close_condition: self.close_condition, + item_width: self.item_width, + item_height: self.item_height, + bar_bounds: layout.bounds(), + main_offset: self.main_offset, + cross_offset: self.cross_offset, + root_bounds_list: root_list, + path_highlight: self.path_highlight, + style: std::borrow::Cow::Owned(self.style.clone()), + position: Point::new(0., 0.), + is_overlay: false, + window_id: id, + depth: 0, + on_surface_action: self.on_surface_action.clone(), + }; + + init_root_menu( + &mut popup_menu, + renderer, + shell, + view_cursor.position().unwrap(), + viewport.size(), + Vector::new(0., 0.), + layout.bounds(), + self.main_offset as f32, + ); + let (anchor_rect, gravity) = my_state.inner.with_data_mut(|state| { + state.popup_id.insert(self.window_id, id); + (state + .menu_states + .iter() + .find(|s| s.index.is_none()) + .map(|s| s.menu_bounds.parent_bounds) + .map_or_else( + || { + let bounds = layout.bounds(); + Rectangle { + x: bounds.x as i32, + y: bounds.y as i32, + width: bounds.width as i32, + height: bounds.height as i32, + } + }, + |r| Rectangle { + x: r.x as i32, + y: r.y as i32, + width: r.width as i32, + height: r.height as i32, + }, + ), match (state.horizontal_direction, state.vertical_direction) { + (Direction::Positive, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight, + (Direction::Positive, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopRight, + (Direction::Negative, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomLeft, + (Direction::Negative, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopLeft, + }) + }); + + let menu_node = popup_menu.layout(renderer, Limits::NONE.min_width(1.).min_height(1.)); + let popup_size = menu_node.size(); + let positioner = SctkPositioner { + size: Some(( + popup_size.width.ceil() as u32 + 2, + popup_size.height.ceil() as u32 + 2, + )), + anchor_rect, + anchor: + cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::BottomLeft, + gravity, + reactive: true, + ..Default::default() + }; + let parent = self.window_id; + shell.publish((surface_action)(crate::surface::action::simple_popup( + move || SctkPopupSettings { + parent, + id, + positioner: positioner.clone(), + parent_size: None, + grab: true, + close_with_children: false, + input_zone: None, + }, + Some(move || { + Element::from(crate::widget::container(popup_menu.clone()).center(Length::Fill)) + .map(crate::action::app) + }), + ))); + } + } } -impl<'a, Message, Renderer> Widget - for MenuBar<'a, Message, Renderer> +impl Widget for MenuBar where - Renderer: renderer::Renderer, + Message: Clone + 'static, { fn size(&self) -> iced_core::Size { iced_core::Size::new(self.width, self.height) } fn diff(&mut self, tree: &mut Tree) { - menu_roots_diff(&mut self.menu_roots, tree); + let state = tree.state.downcast_mut::(); + state + .inner + .with_data_mut(|inner| menu_roots_diff(&mut self.menu_roots, &mut inner.tree)); } fn tag(&self) -> tree::Tag { @@ -304,14 +550,14 @@ where menu_roots_children(&self.menu_roots) } - fn layout(&self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node { + fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node { use super::flex; let limits = limits.width(self.width).height(self.height); - let children = self + let mut children = self .menu_roots - .iter() - .map(|root| &root.item) + .iter_mut() + .map(|root| &mut root.item) .collect::>(); // the first children of the tree are the menu roots items let mut tree_children = tree @@ -319,38 +565,39 @@ where .iter_mut() .map(|t| &mut t.children[0]) .collect::>(); - flex::resolve( + flex::resolve_wrapper( &flex::Axis::Horizontal, renderer, &limits, self.padding, self.spacing, Alignment::Center, - &children, + &mut children, &mut tree_children, ) } - fn on_event( + #[allow(clippy::too_many_lines)] + fn update( &mut self, tree: &mut Tree, - event: event::Event, + event: &event::Event, layout: Layout<'_>, view_cursor: Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { + ) { use event::Event::{Mouse, Touch}; use mouse::{Button::Left, Event::ButtonReleased}; use touch::Event::{FingerLifted, FingerLost}; - let root_status = process_root_events( + process_root_events( &mut self.menu_roots, view_cursor, tree, - &event, + event, layout, renderer, clipboard, @@ -358,18 +605,96 @@ where viewport, ); - let state = tree.state.downcast_mut::(); + let my_state = tree.state.downcast_mut::(); + + // XXX this should reset the state if there are no other copies of the state, which implies no dropdown menus open. + let reset = self.window_id != window::Id::NONE + && my_state + .inner + .with_data(|d| !d.open && !d.active_root.is_empty()); + + let open = my_state.inner.with_data_mut(|state| { + if reset { + if let Some(popup_id) = state.popup_id.get(&self.window_id).copied() { + if let Some(handler) = self.on_surface_action.as_ref() { + shell.publish((handler)(crate::surface::Action::DestroyPopup(popup_id))); + state.reset(); + } + } + } + state.open + }); match event { + Mouse(mouse::Event::ButtonPressed(Left)) + | Touch(touch::Event::FingerPressed { .. }) + if view_cursor.is_over(layout.bounds()) => + { + // TODO should we track that it has been pressed? + shell.capture_event(); + } Mouse(ButtonReleased(Left)) | Touch(FingerLifted { .. } | FingerLost { .. }) => { - if state.menu_states.is_empty() && view_cursor.is_over(layout.bounds()) { - state.view_cursor = view_cursor; - state.open = true; + let create_popup = my_state.inner.with_data_mut(|state| { + let mut create_popup = false; + if state.menu_states.is_empty() && view_cursor.is_over(layout.bounds()) { + state.view_cursor = view_cursor; + state.open = true; + create_popup = true; + } else if let Some(_id) = state.popup_id.remove(&self.window_id) { + state.menu_states.clear(); + state.active_root.clear(); + state.open = false; + #[cfg(all( + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + { + let surface_action = self.on_surface_action.as_ref().unwrap(); + shell.capture_event(); + + shell.publish(surface_action(crate::surface::action::destroy_popup( + _id, + ))); + } + state.view_cursor = view_cursor; + } + create_popup + }); + + if !create_popup { + return; + } + shell.capture_event(); + #[cfg(all( + feature = "multi-window", + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) { + self.create_popup(layout, view_cursor, renderer, shell, viewport, my_state); + } + } + Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorEntered) + if open && view_cursor.is_over(layout.bounds()) => + { + shell.capture_event(); + #[cfg(all( + feature = "multi-window", + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) { + self.create_popup(layout, view_cursor, renderer, shell, viewport, my_state); } } _ => (), } - root_status } fn draw( @@ -384,66 +709,85 @@ where ) { let state = tree.state.downcast_ref::(); let cursor_pos = view_cursor.position().unwrap_or_default(); - let position = if state.open && (cursor_pos.x < 0.0 || cursor_pos.y < 0.0) { - state.view_cursor - } else { - view_cursor - }; + state.inner.with_data_mut(|state| { + let position = if state.open && (cursor_pos.x < 0.0 || cursor_pos.y < 0.0) { + state.view_cursor + } else { + view_cursor + }; - // draw path highlight - if self.path_highlight.is_some() { - let styling = theme.appearance(&self.style); - if let Some(active) = state.active_root { - let active_bounds = layout - .children() - .nth(active) - .expect("Active child not found in menu?") - .bounds(); - let path_quad = renderer::Quad { - bounds: active_bounds, - border: Border { - radius: styling.bar_border_radius.into(), - ..Default::default() - }, - shadow: Default::default(), - }; + // draw path highlight + if self.path_highlight.is_some() { + let styling = theme.appearance(&self.style); + if let Some(active) = state.active_root.first() { + let active_bounds = layout + .children() + .nth(*active) + .expect("Active child not found in menu?") + .bounds(); + let path_quad = renderer::Quad { + bounds: active_bounds, + border: Border { + radius: styling.bar_border_radius.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }; - renderer.fill_quad(path_quad, styling.path); + renderer.fill_quad(path_quad, styling.path); + } } - } - self.menu_roots - .iter() - .zip(&tree.children) - .zip(layout.children()) - .for_each(|((root, t), lo)| { - root.item.as_widget().draw( - &t.children[root.index], - renderer, - theme, - style, - lo, - position, - viewport, - ); - }); + self.menu_roots + .iter() + .zip(&tree.children) + .zip(layout.children()) + .for_each(|((root, t), lo)| { + root.item.draw( + &t.children[root.index], + renderer, + theme, + style, + lo, + position, + viewport, + ); + }); + }); } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, _renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { + #[cfg(all( + feature = "multi-window", + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) + && self.on_surface_action.is_some() + && self.window_id != window::Id::NONE + { + return None; + } + let state = tree.state.downcast_ref::(); - if !state.open { + if state.inner.with_data(|state| !state.open) { return None; } Some( Menu { - tree, - menu_roots: &mut self.menu_roots, + tree: state.clone(), + menu_roots: std::borrow::Cow::Owned(self.menu_roots.clone()), bounds_expand: self.bounds_expand, menu_overlays_parent: false, close_condition: self.close_condition, @@ -454,26 +798,30 @@ where cross_offset: self.cross_offset, root_bounds_list: layout.children().map(|lo| lo.bounds()).collect(), path_highlight: self.path_highlight, - style: &self.style, + style: std::borrow::Cow::Borrowed(&self.style), + position: Point::new(translation.x, translation.y), + is_overlay: true, + window_id: window::Id::NONE, + depth: 0, + on_surface_action: self.on_surface_action.clone(), } .overlay(), ) } } -impl<'a, Message, Renderer> From> - for Element<'a, Message, crate::Theme, Renderer> + +impl From> for Element<'_, Message, crate::Theme, Renderer> where - Message: 'a, - Renderer: 'a + renderer::Renderer, + Message: Clone + 'static, { - fn from(value: MenuBar<'a, Message, Renderer>) -> Self { + fn from(value: MenuBar) -> Self { Self::new(value) } } #[allow(unused_results, clippy::too_many_arguments)] -fn process_root_events( - menu_roots: &mut [MenuTree<'_, Message, Renderer>], +fn process_root_events( + menu_roots: &mut [MenuTree], view_cursor: Cursor, tree: &mut Tree, event: &event::Event, @@ -482,26 +830,22 @@ fn process_root_events( clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, -) -> event::Status -where - Renderer: renderer::Renderer, -{ - menu_roots +) { + for ((root, t), lo) in menu_roots .iter_mut() .zip(&mut tree.children) .zip(layout.children()) - .map(|((root, t), lo)| { - // assert!(t.tag == tree::Tag::stateless()); - root.item.as_widget_mut().on_event( - &mut t.children[root.index], - event.clone(), - lo, - view_cursor, - renderer, - clipboard, - shell, - viewport, - ) - }) - .fold(event::Status::Ignored, event::Status::merge) + { + // assert!(t.tag == tree::Tag::stateless()); + root.item.update( + &mut t.children[root.index], + event, + lo, + view_cursor, + renderer, + clipboard, + shell, + viewport, + ); + } } diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index bc0b7725..74afe60f 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -1,17 +1,27 @@ // From iced_aw, license MIT //! Menu tree overlay +use std::{borrow::Cow, sync::Arc}; + use super::{menu_bar::MenuBarState, menu_tree::MenuTree}; +#[cfg(all( + feature = "multi-window", + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" +))] +use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem}; use crate::style::menu_bar::StyleSheet; -use iced_core::{Border, Shadow}; +use iced::window; +use iced_core::{Border, Renderer as IcedRenderer, Shadow, Widget}; use iced_widget::core::{ - event, + Clipboard, Layout, Length, Padding, Point, Rectangle, Shell, Size, Vector, event, layout::{Limits, Node}, mouse::{self, Cursor}, overlay, renderer, touch, widget::Tree, - Clipboard, Layout, Length, Padding, Point, Rectangle, Shell, Size, Vector, }; /// The condition of when to close a menu @@ -228,20 +238,21 @@ pub(super) struct MenuSlice { pub(super) upper_bound_rel: f32, } +#[derive(Debug, Clone)] /// Menu bounds in overlay space -struct MenuBounds { +pub struct MenuBounds { child_positions: Vec, child_sizes: Vec, children_bounds: Rectangle, - parent_bounds: Rectangle, + pub parent_bounds: Rectangle, check_bounds: Rectangle, offset_bounds: Rectangle, } impl MenuBounds { #[allow(clippy::too_many_arguments)] - fn new( - menu_tree: &MenuTree<'_, Message, Renderer>, - renderer: &Renderer, + fn new( + menu_tree: &MenuTree, + renderer: &crate::Renderer, item_width: ItemWidth, item_height: ItemHeight, viewport_size: Size, @@ -250,10 +261,8 @@ impl MenuBounds { bounds_expand: u16, parent_bounds: Rectangle, tree: &mut [Tree], - ) -> Self - where - Renderer: renderer::Renderer, - { + is_overlay: bool, + ) -> Self { let (children_size, child_positions, child_sizes) = get_children_layout(menu_tree, renderer, item_width, item_height, tree); @@ -263,7 +272,11 @@ impl MenuBounds { // overlay space children position let (children_position, offset_position) = { let (cp, op) = aod.resolve(view_parent_bounds, children_size, viewport_size); - (cp - overlay_offset, op - overlay_offset) + if is_overlay { + (cp - overlay_offset, op - overlay_offset) + } else { + (Point::ORIGIN, op - overlay_offset) + } }; // calc offset bounds @@ -276,7 +289,7 @@ impl MenuBounds { let offset_bounds = Rectangle::new(offset_position, offset_size); let children_bounds = Rectangle::new(children_position, children_size); - let check_bounds = pad_rectangle(children_bounds, [bounds_expand; 4].into()); + let check_bounds = pad_rectangle(children_bounds, bounds_expand.into()); Self { child_positions, @@ -289,23 +302,22 @@ impl MenuBounds { } } +#[derive(Clone)] pub(crate) struct MenuState { - pub(super) index: Option, + /// The index of the active menu item + pub(crate) index: Option, scroll_offset: f32, - menu_bounds: MenuBounds, + pub menu_bounds: MenuBounds, } impl MenuState { - pub(super) fn layout( - &self, + pub(super) fn layout( + &mut self, overlay_offset: Vector, slice: MenuSlice, - renderer: &Renderer, - menu_tree: &MenuTree<'_, Message, Renderer>, + renderer: &crate::Renderer, + menu_tree: &[MenuTree], tree: &mut [Tree], - ) -> Node - where - Renderer: renderer::Renderer, - { + ) -> Node { let MenuSlice { start_index, end_index, @@ -313,18 +325,14 @@ impl MenuState { upper_bound_rel, } = slice; - assert_eq!( - menu_tree.children.len(), - self.menu_bounds.child_positions.len() - ); + debug_assert_eq!(menu_tree.len(), self.menu_bounds.child_positions.len()); // viewport space children bounds let children_bounds = self.menu_bounds.children_bounds + overlay_offset; - let child_nodes = self.menu_bounds.child_positions[start_index..=end_index] - .iter() - .zip(self.menu_bounds.child_sizes[start_index..=end_index].iter()) - .zip(menu_tree.children[start_index..=end_index].iter()) + .iter_mut() + .zip(self.menu_bounds.child_sizes[start_index..=end_index].iter_mut()) + .zip(menu_tree[start_index..=end_index].iter()) .map(|((cp, size), mt)| { let mut position = *cp; let mut size = *size; @@ -337,11 +345,14 @@ impl MenuState { size.height = upper_bound_rel - position; } - let limits = Limits::new(Size::ZERO, size); + let limits = Limits::new(size, size); mt.item - .as_widget() - .layout(&mut tree[mt.index], renderer, &limits) + .element + .with_data_mut(|e| { + e.as_widget_mut() + .layout(&mut tree[mt.index], renderer, &limits) + }) .move_to(Point::new(0.0, position + self.scroll_offset)) }) .collect::>(); @@ -349,30 +360,28 @@ impl MenuState { Node::with_children(children_bounds.size(), child_nodes).move_to(children_bounds.position()) } - fn layout_single( + fn layout_single( &self, overlay_offset: Vector, index: usize, - renderer: &Renderer, - menu_tree: &MenuTree<'_, Message, Renderer>, + renderer: &crate::Renderer, + menu_tree: &mut MenuTree, tree: &mut Tree, - ) -> Node - where - Renderer: renderer::Renderer, - { + ) -> Node { // viewport space children bounds let children_bounds = self.menu_bounds.children_bounds + overlay_offset; let position = self.menu_bounds.child_positions[index]; let limits = Limits::new(Size::ZERO, self.menu_bounds.child_sizes[index]); let parent_offset = children_bounds.position() - Point::ORIGIN; - let node = menu_tree.item.as_widget().layout(tree, renderer, &limits); - node.clone().move_to(Point::new( + let node = menu_tree.item.layout(tree, renderer, &limits); + node.move_to(Point::new( parent_offset.x, parent_offset.y + position + self.scroll_offset, )) } + /// returns a slice of the menu items that are inside the viewport pub(super) fn slice( &self, viewport_size: Size, @@ -427,12 +436,11 @@ impl MenuState { } } -pub(crate) struct Menu<'a, 'b, Message, Renderer> -where - Renderer: renderer::Renderer, -{ - pub(crate) tree: &'b mut Tree, - pub(crate) menu_roots: &'b mut Vec>, +#[derive(Clone)] +pub(crate) struct Menu<'b, Message: std::clone::Clone> { + pub(crate) tree: MenuBarState, + // Flattened menu tree + pub(crate) menu_roots: Cow<'b, [MenuTree]>, pub(crate) bounds_expand: u16, /// Allows menu overlay items to overlap the parent pub(crate) menu_overlays_parent: bool, @@ -444,76 +452,113 @@ where pub(crate) cross_offset: i32, pub(crate) root_bounds_list: Vec, pub(crate) path_highlight: Option, - pub(crate) style: &'b ::Style, + pub(crate) style: Cow<'b, ::Style>, + pub(crate) position: Point, + pub(crate) is_overlay: bool, + /// window id for this popup + pub(crate) window_id: window::Id, + pub(crate) depth: usize, + pub(crate) on_surface_action: + Option Message + Send + Sync + 'static>>, } -impl<'a, 'b, Message, Renderer> Menu<'a, 'b, Message, Renderer> -where - Renderer: renderer::Renderer, -{ - pub(crate) fn overlay(self) -> overlay::Element<'b, Message, crate::Theme, Renderer> { - overlay::Element::new(Point::ORIGIN, Box::new(self)) +impl<'b, Message: Clone + 'static> Menu<'b, Message> { + pub(crate) fn overlay(self) -> overlay::Element<'b, Message, crate::Theme, crate::Renderer> { + overlay::Element::new(Box::new(self)) } -} -impl<'a, 'b, Message, Renderer> overlay::Overlay - for Menu<'a, 'b, Message, Renderer> -where - Renderer: renderer::Renderer, -{ - fn layout( - &mut self, - renderer: &Renderer, - bounds: Size, - position: Point, - _translation: iced::Vector, - ) -> Node { - // layout children - let state = self.tree.state.downcast_mut::(); - let overlay_offset = Point::ORIGIN - position; - let tree_children = &mut self.tree.children; - let children = state - .active_root - .map(|active_root| { - let root = &self.menu_roots[active_root]; - let active_tree = &mut tree_children[active_root]; - state.menu_states.iter().enumerate().fold( - (root, Vec::new()), - |(menu_root, mut nodes), (_i, ms)| { - let slice = ms.slice(bounds, overlay_offset, self.item_height); - let _start_index = slice.start_index; - let _end_index = slice.end_index; - let children_node = ms.layout( - overlay_offset, - slice, - renderer, - menu_root, - &mut active_tree.children, - ); - nodes.push(children_node); - // only the last menu can have a None active index - ( - ms.index - .map_or(menu_root, |active| &menu_root.children[active]), - nodes, - ) - }, - ) + + pub(crate) fn layout(&self, renderer: &crate::Renderer, limits: Limits) -> Node { + // layout children; + let position = self.position; + let mut intrinsic_size = Size::ZERO; + + let empty = Vec::new(); + self.tree.inner.with_data_mut(|data| { + if data.active_root.len() < self.depth + 1 || data.menu_states.len() < self.depth + 1 { + return Node::new(limits.min()); + } + + let overlay_offset = Point::ORIGIN - position; + let tree_children: &mut Vec = &mut data.tree.children; + + let children = (if self.is_overlay { 0 } else { self.depth }..=if self.is_overlay { + data.active_root.len() - 1 + } else { + self.depth }) - .map(|(_, l)| l) - .unwrap_or_default(); + .map(|active_root| { + if self.menu_roots.is_empty() { + return (&empty, vec![]); + } + let (active_tree, roots) = + data.active_root[..=active_root].iter().skip(1).fold( + ( + &mut tree_children[data.active_root[0]].children, + &self.menu_roots[data.active_root[0]].children, + ), + |(tree, mt), next_active_root| (tree, &mt[*next_active_root].children), + ); - // overlay space viewport rectangle - Node::with_children(bounds, children).translate(Point::ORIGIN - position) + data.menu_states[if self.is_overlay { 0 } else { self.depth } + ..=if self.is_overlay { + data.active_root.len() - 1 + } else { + self.depth + }] + .iter_mut() + .enumerate() + .filter(|ms| self.is_overlay || ms.0 < 1) + .fold( + (roots, Vec::new()), + |(menu_root, mut nodes), (_i, ms)| { + let slice = + ms.slice(limits.max(), overlay_offset, self.item_height); + let _start_index = slice.start_index; + let _end_index = slice.end_index; + let children_node = ms.layout( + overlay_offset, + slice, + renderer, + menu_root, + active_tree, + ); + let node_size = children_node.size(); + intrinsic_size.height += node_size.height; + intrinsic_size.width = intrinsic_size.width.max(node_size.width); + + nodes.push(children_node); + // if popup just use len 1? + // only the last menu can have a None active index + ( + ms.index + .map_or(menu_root, |active| &menu_root[active].children), + nodes, + ) + }, + ) + }) + .map(|(_, l)| l) + .next() + .unwrap_or_default(); + + // overlay space viewport rectangle + Node::with_children( + limits.resolve(Length::Shrink, Length::Shrink, intrinsic_size), + children, + ) + .translate(Point::ORIGIN - position) + }) } - fn on_event( + #[allow(clippy::too_many_lines)] + fn update( &mut self, - event: event::Event, + event: &event::Event, layout: Layout<'_>, view_cursor: Cursor, - renderer: &Renderer, + renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - ) -> event::Status { + ) -> Option<(usize, MenuState)> { use event::{ Event::{Mouse, Touch}, Status::{Captured, Ignored}, @@ -524,19 +569,26 @@ where }; use touch::Event::{FingerLifted, FingerMoved, FingerPressed}; - if !self.tree.state.downcast_ref::().open { - return Ignored; - }; + if !self + .tree + .inner + .with_data(|data| data.open || data.active_root.len() <= self.depth) + { + return None; + } let viewport = layout.bounds(); + let viewport_size = viewport.size(); let overlay_offset = Point::ORIGIN - viewport.position(); let overlay_cursor = view_cursor.position().unwrap_or_default() - overlay_offset; - - let menu_status = process_menu_events( - self.tree, - self.menu_roots, - event.clone(), + let menu_roots = match &mut self.menu_roots { + Cow::Borrowed(_) => panic!(), + Cow::Owned(o) => o.as_mut_slice(), + }; + process_menu_events( + self, + event, view_cursor, renderer, clipboard, @@ -556,22 +608,29 @@ where ); match event { - Mouse(WheelScrolled { delta }) => { - process_scroll_events(self, delta, overlay_cursor, viewport_size, overlay_offset) - .merge(menu_status) - } + Mouse(WheelScrolled { delta }) => process_scroll_events( + self, + shell, + *delta, + overlay_cursor, + viewport_size, + overlay_offset, + ), Mouse(ButtonPressed(Left)) | Touch(FingerPressed { .. }) => { - let state = self.tree.state.downcast_mut::(); - state.pressed = true; - state.view_cursor = view_cursor; - Captured + self.tree.inner.with_data_mut(|data| { + data.pressed = true; + data.view_cursor = view_cursor; + }); } Mouse(CursorMoved { position }) | Touch(FingerMoved { position, .. }) => { - let view_cursor = Cursor::Available(position); + let view_cursor = Cursor::Available(*position); let overlay_cursor = view_cursor.position().unwrap_or_default() - overlay_offset; - process_overlay_events( + if !self.is_overlay && !view_cursor.is_over(viewport) { + return None; + } + let new_root = process_overlay_events( self, renderer, viewport_size, @@ -579,163 +638,503 @@ where view_cursor, overlay_cursor, self.cross_offset as f32, - ) - .merge(menu_status) - } + shell, + ); - Mouse(ButtonReleased(Left)) | Touch(FingerLifted { .. }) => { - let state = self.tree.state.downcast_mut::(); - state.pressed = false; - - // process close condition - if state - .view_cursor - .position() - .unwrap_or_default() - .distance(view_cursor.position().unwrap_or_default()) - < 2.0 - { - let is_inside = state - .menu_states - .iter() - .any(|ms| ms.menu_bounds.check_bounds.contains(overlay_cursor)); - - if self.close_condition.click_inside && is_inside { - state.reset(); - return Captured; - } - - if self.close_condition.click_outside && !is_inside { - state.reset(); - return Captured; - } + if self.is_overlay && view_cursor.is_over(viewport) { + shell.capture_event(); } - // close all menus when clicking inside the menu bar - if self.bar_bounds.contains(overlay_cursor) { - state.reset(); - Captured - } else { - menu_status - } + return new_root; } - _ => menu_status, - } + Mouse(ButtonReleased(_)) | Touch(FingerLifted { .. }) => { + self.tree.inner.with_data_mut(|state| { + state.pressed = false; + + // process close condition + if state + .view_cursor + .position() + .unwrap_or_default() + .distance(view_cursor.position().unwrap_or_default()) + < 2.0 + { + let is_inside = state.menu_states[..=if self.is_overlay { + state.active_root.len().saturating_sub(1) + } else { + self.depth + }] + .iter() + .any(|ms| ms.menu_bounds.check_bounds.contains(overlay_cursor)); + let mut needs_reset = false; + needs_reset |= self.close_condition.click_inside + && is_inside + && matches!( + event, + Mouse(ButtonReleased(Left)) | Touch(FingerLifted { .. }) + ); + + needs_reset |= self.close_condition.click_outside && !is_inside; + + if needs_reset { + #[cfg(all( + feature = "multi-window", + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) + && let Some(handler) = self.on_surface_action.as_ref() + { + let mut root = self.window_id; + let mut depth = self.depth; + while let Some(parent) = + state.popup_id.iter().find(|(_, v)| **v == root) + { + // parent of root popup is the window, so we stop. + if depth == 0 { + break; + } + root = *parent.0; + depth = depth.saturating_sub(1); + } + shell + .publish((handler)(crate::surface::Action::DestroyPopup(root))); + } + + state.reset(); + } + } + + // close all menus when clicking inside the menu bar + if self.bar_bounds.contains(overlay_cursor) { + state.reset(); + } + }); + } + + _ => {} + }; + None } - #[allow(unused_results)] + #[allow(unused_results, clippy::too_many_lines)] fn draw( &self, - renderer: &mut Renderer, + renderer: &mut crate::Renderer, theme: &crate::Theme, style: &renderer::Style, layout: Layout<'_>, view_cursor: Cursor, ) { - let state = self.tree.state.downcast_ref::(); - let Some(active_root) = state.active_root else { - return; - }; + self.tree.inner.with_data(|state| { + if !state.open || state.active_root.len() <= self.depth { + return; + } + let active_root = &state.active_root[..=if self.is_overlay { 0 } else { self.depth }]; + let viewport = layout.bounds(); + let viewport_size = viewport.size(); + let overlay_offset = Point::ORIGIN - viewport.position(); - let viewport = layout.bounds(); - let viewport_size = viewport.size(); - let overlay_offset = Point::ORIGIN - viewport.position(); - let render_bounds = Rectangle::new(Point::ORIGIN, viewport.size()); + let render_bounds = if self.is_overlay { + Rectangle::new(Point::ORIGIN, viewport.size()) + } else { + Rectangle::new(Point::ORIGIN, Size::INFINITE) + }; - let styling = theme.appearance(self.style); + let styling = theme.appearance(&self.style); + let roots = active_root.iter().skip(1).fold( + &self.menu_roots[active_root[0]].children, + |mt, next_active_root| &mt[*next_active_root].children, + ); + let indices = state.get_trimmed_indices(self.depth).collect::>(); + state.menu_states[if self.is_overlay { 0 } else { self.depth }..=if self.is_overlay { + state.menu_states.len() - 1 + } else { + self.depth + }] + .iter() + .zip(layout.children()) + .enumerate() + .filter(|ms: &(usize, (&MenuState, Layout<'_>))| self.is_overlay || ms.0 < 1) + .fold( + roots, + |menu_roots: &Vec>, (i, (ms, children_layout))| { + let draw_path = self.path_highlight.as_ref().is_some_and(|ph| match ph { + PathHighlight::Full => true, + PathHighlight::OmitActive => { + !indices.is_empty() && i < indices.len() - 1 + } + PathHighlight::MenuActive => { + !indices.is_empty() + && i < indices.len() + && menu_roots.len() > indices[i] + && (i < indices.len() - 1 + || !menu_roots[indices[i]].children.is_empty()) + } + }); - let tree = &self.tree.children[active_root].children; - let root = &self.menu_roots[active_root]; - - let indices = state.get_trimmed_indices().collect::>(); - - state - .menu_states - .iter() - .zip(layout.children()) - .enumerate() - .fold(root, |menu_root, (i, (ms, children_layout))| { - let draw_path = self.path_highlight.as_ref().map_or(false, |ph| match ph { - PathHighlight::Full => true, - PathHighlight::OmitActive => !indices.is_empty() && i < indices.len() - 1, - PathHighlight::MenuActive => i < state.menu_states.len() - 1, - }); - - // react only to the last menu - let view_cursor = if i == state.menu_states.len() - 1 { - view_cursor - } else { - Cursor::Available([-1.0; 2].into()) - }; - - let draw_menu = |r: &mut Renderer| { - // calc slice - let slice = ms.slice(viewport_size, overlay_offset, self.item_height); - let start_index = slice.start_index; - let end_index = slice.end_index; - - let children_bounds = children_layout.bounds(); - - // draw menu background - // let bounds = pad_rectangle(children_bounds, styling.background_expand.into()); - // println!("cursor: {:?}", view_cursor); - // println!("bg_bounds: {:?}", bounds); - // println!("color: {:?}\n", styling.background); - let menu_quad = renderer::Quad { - bounds: pad_rectangle(children_bounds, styling.background_expand.into()), - border: Border { - radius: styling.menu_border_radius.into(), - width: styling.border_width, - color: styling.border_color, - }, - shadow: Shadow::default(), - }; - let menu_color = styling.background; - r.fill_quad(menu_quad, menu_color); - - // draw path hightlight - if let (true, Some(active)) = (draw_path, ms.index) { - let active_bounds = children_layout - .children() - .nth(active.saturating_sub(start_index)) - .expect("No active children were found in menu?") - .bounds(); - let path_quad = renderer::Quad { - bounds: active_bounds, - border: Border { - radius: styling.menu_border_radius.into(), - ..Default::default() - }, - shadow: Shadow::default(), + // react only to the last menu + let view_cursor = if self.depth == state.active_root.len() - 1 + || i == state.menu_states.len() - 1 + { + view_cursor + } else { + Cursor::Available([-1.0; 2].into()) }; - r.fill_quad(path_quad, styling.path); - } + let draw_menu = |r: &mut crate::Renderer| { + // calc slice + let slice = ms.slice(viewport_size, overlay_offset, self.item_height); + let start_index = slice.start_index; + let end_index = slice.end_index; - // draw item - menu_root.children[start_index..=end_index] - .iter() - .zip(children_layout.children()) - .for_each(|(mt, clo)| { - mt.item.as_widget().draw( - &tree[mt.index], - r, - theme, - style, - clo, - view_cursor, - &children_layout.bounds(), - ); - }); + let children_bounds = children_layout.bounds(); + + // draw menu background + // let bounds = pad_rectangle(children_bounds, styling.background_expand.into()); + // println!("cursor: {:?}", view_cursor); + // println!("bg_bounds: {:?}", bounds); + // println!("color: {:?}\n", styling.background); + let menu_quad = renderer::Quad { + bounds: pad_rectangle( + children_bounds.intersection(&viewport).unwrap_or_default(), + styling.background_expand.into(), + ), + border: Border { + radius: styling.menu_border_radius.into(), + width: styling.border_width, + color: styling.border_color, + }, + shadow: Shadow::default(), + snap: true, + }; + let menu_color = styling.background; + r.fill_quad(menu_quad, menu_color); + // draw path hightlight + if let (true, Some(active)) = (draw_path, ms.index) + && let Some(active_layout) = children_layout + .children() + .nth(active.saturating_sub(start_index)) + { + let path_quad = renderer::Quad { + bounds: active_layout + .bounds() + .intersection(&viewport) + .unwrap_or_default(), + border: Border { + radius: styling.menu_border_radius.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }; + + r.fill_quad(path_quad, styling.path); + } + if start_index < menu_roots.len() { + // draw item + menu_roots[start_index..=end_index] + .iter() + .zip(children_layout.children()) + .for_each(|(mt, clo)| { + mt.item.draw( + &state.tree.children[active_root[0]].children[mt.index], + r, + theme, + style, + clo, + view_cursor, + &children_layout + .bounds() + .intersection(&viewport) + .unwrap_or_default(), + ); + }); + } + }; + + renderer.with_layer(render_bounds, draw_menu); + + // only the last menu can have a None active index + ms.index + .map_or(menu_roots, |active| &menu_roots[active].children) + }, + ); + }); + } +} +impl overlay::Overlay + for Menu<'_, Message> +{ + fn layout(&mut self, renderer: &crate::Renderer, bounds: Size) -> iced_core::layout::Node { + Menu::layout( + self, + renderer, + Limits::NONE + .min_width(bounds.width) + .max_width(bounds.width) + .min_height(bounds.height) + .max_height(bounds.height), + ) + } + + fn update( + &mut self, + event: &iced::Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + self.update(event, layout, cursor, renderer, clipboard, shell); + } + + fn draw( + &self, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + self.draw(renderer, theme, style, layout, cursor); + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &crate::Renderer, + ) -> mouse::Interaction { + if cursor.is_over(layout.bounds()) { + mouse::Interaction::Idle + } else { + mouse::Interaction::None + } + } +} + +impl Widget + for Menu<'_, Message> +{ + fn size(&self) -> Size { + Size { + width: Length::Shrink, + height: Length::Shrink, + } + } + + fn layout( + &mut self, + _tree: &mut Tree, + renderer: &crate::Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + Menu::layout(self, renderer, *limits) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + Menu::draw(self, renderer, theme, style, layout, cursor); + } + + #[allow(clippy::too_many_lines)] + fn update( + &mut self, + tree: &mut Tree, + event: &iced::Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let new_root = self.update(event, layout, cursor, renderer, clipboard, shell); + + #[cfg(all( + feature = "multi-window", + feature = "wayland", + feature = "winit", + feature = "surface-message", + target_os = "linux" + ))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) + && let Some((new_root, new_ms)) = new_root + { + use iced_runtime::platform_specific::wayland::popup::{ + SctkPopupSettings, SctkPositioner, + }; + let overlay_offset = Point::ORIGIN - viewport.position(); + + let overlay_cursor = cursor.position().unwrap_or_default() - overlay_offset; + + let Some((mut menu, popup_id)) = self.tree.inner.with_data_mut(|state| { + let popup_id = *state + .popup_id + .entry(self.window_id) + .or_insert_with(window::Id::unique); + let active_roots = state + .active_root + .get(self.depth) + .cloned() + .unwrap_or_default(); + + let root_bounds_list = layout + .children() + .next() + .unwrap() + .children() + .map(|lo| lo.bounds()) + .collect(); + + let mut popup_menu = Menu { + tree: self.tree.clone(), + menu_roots: Cow::Owned(Cow::into_owned(self.menu_roots.clone())), + bounds_expand: self.bounds_expand, + menu_overlays_parent: false, + close_condition: self.close_condition, + item_width: self.item_width, + item_height: self.item_height, + bar_bounds: layout.bounds(), + main_offset: self.main_offset, + cross_offset: self.cross_offset, + root_bounds_list, + path_highlight: self.path_highlight, + style: Cow::Owned(Cow::into_owned(self.style.clone())), + position: Point::new(0., 0.), + is_overlay: false, + window_id: popup_id, + depth: self.depth + 1, + on_surface_action: self.on_surface_action.clone(), }; - renderer.with_layer(render_bounds, draw_menu); + state.active_root.push(new_root); - // only the last menu can have a None active index - ms.index - .map_or(menu_root, |active| &menu_root.children[active]) + Some((popup_menu, popup_id)) + }) else { + return; + }; + // XXX we push a new active root manually instead + init_root_popup_menu( + &mut menu, + renderer, + shell, + cursor.position().unwrap_or_default(), + layout.bounds().size(), + Vector::new(0., 0.), + layout.bounds(), + self.main_offset as f32, + ); + let (anchor_rect, gravity) = self.tree.inner.with_data_mut(|state| { + (state + .menu_states + .get(self.depth + 1) + .map(|s| s.menu_bounds.parent_bounds) + .map_or_else( + || { + let bounds = layout.bounds(); + Rectangle { + x: bounds.x as i32, + y: bounds.y as i32, + width: bounds.width as i32, + height: bounds.height as i32, + } + }, + |r| Rectangle { + x: r.x as i32, + y: r.y as i32, + width: r.width as i32, + height: r.height as i32, + }, + ), match (state.horizontal_direction, state.vertical_direction) { + (Direction::Positive, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight, + (Direction::Positive, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopRight, + (Direction::Negative, Direction::Positive) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomLeft, + (Direction::Negative, Direction::Negative) => cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::TopLeft, + }) }); + + let menu_node = Widget::layout( + &mut menu, + &mut Tree::empty(), + renderer, + &Limits::NONE.min_width(1.).min_height(1.), + ); + + let popup_size = menu_node.size(); + let mut positioner = SctkPositioner { + size: Some(( + popup_size.width.ceil() as u32 + 2, + popup_size.height.ceil() as u32 + 2, + )), + anchor_rect, + anchor: + cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::TopRight, + gravity, + reactive: true, + ..Default::default() + }; + // disable slide_x if it is set in the default + positioner.constraint_adjustment &= !(1 << 0); + let parent = self.window_id; + shell.publish((self.on_surface_action.as_ref().unwrap())( + crate::surface::action::simple_popup( + move || SctkPopupSettings { + parent, + id: popup_id, + positioner: positioner.clone(), + parent_size: None, + grab: true, + close_with_children: false, + input_zone: None, + }, + Some(move || { + crate::Element::from( + crate::widget::container(menu.clone()).center(Length::Fill), + ) + .map(crate::action::app) + }), + ), + )); + } + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &crate::Renderer, + ) -> mouse::Interaction { + if cursor.is_over(layout.bounds()) { + mouse::Interaction::Idle + } else { + mouse::Interaction::None + } + } +} + +impl<'a, Message> From> + for iced::Element<'a, Message, crate::Theme, crate::Renderer> +where + Message: std::clone::Clone + 'static, +{ + fn from(value: Menu<'a, Message>) -> Self { + Self::new(value) } } @@ -743,14 +1142,100 @@ fn pad_rectangle(rect: Rectangle, padding: Padding) -> Rectangle { Rectangle { x: rect.x - padding.left, y: rect.y - padding.top, - width: rect.width + padding.horizontal(), - height: rect.height + padding.vertical(), + width: rect.width + padding.x(), + height: rect.height + padding.y(), } } -pub(super) fn init_root_menu( - menu: &mut Menu<'_, '_, Message, Renderer>, - renderer: &Renderer, +#[allow(clippy::too_many_arguments)] +pub(crate) fn init_root_menu( + menu: &mut Menu<'_, Message>, + renderer: &crate::Renderer, + shell: &mut Shell<'_, Message>, + overlay_cursor: Point, + viewport_size: Size, + overlay_offset: Vector, + bar_bounds: Rectangle, + main_offset: f32, +) { + menu.tree.inner.with_data_mut(|state| { + if !(state.menu_states.get(menu.depth).is_none() + && (!menu.is_overlay || bar_bounds.contains(overlay_cursor))) + || menu.depth > 0 + || !state.open + { + return; + } + + for (i, (&root_bounds, mt)) in menu + .root_bounds_list + .iter() + .zip(menu.menu_roots.iter()) + .enumerate() + { + if mt.children.is_empty() { + continue; + } + + if root_bounds.contains(overlay_cursor) { + let view_center = viewport_size.width * 0.5; + let rb_center = root_bounds.center_x(); + + state.horizontal_direction = if menu.is_overlay && rb_center > view_center { + Direction::Negative + } else { + Direction::Positive + }; + + let aod = Aod { + horizontal: true, + vertical: true, + horizontal_overlap: true, + vertical_overlap: false, + horizontal_direction: state.horizontal_direction, + vertical_direction: state.vertical_direction, + horizontal_offset: 0.0, + vertical_offset: main_offset, + }; + let menu_bounds = MenuBounds::new( + mt, + renderer, + menu.item_width, + menu.item_height, + viewport_size, + overlay_offset, + &aod, + menu.bounds_expand, + root_bounds, + &mut state.tree.children[0].children, + menu.is_overlay, + ); + state.active_root.push(i); + let ms = MenuState { + index: None, + scroll_offset: 0.0, + menu_bounds, + }; + state.menu_states.push(ms); + // Hack to ensure menu opens properly + shell.invalidate_layout(); + + break; + } + } + }); +} + +#[cfg(all( + feature = "multi-window", + feature = "wayland", + target_os = "linux", + feature = "winit", + feature = "surface-message" +))] +pub(super) fn init_root_popup_menu( + menu: &mut Menu<'_, Message>, + renderer: &crate::Renderer, shell: &mut Shell<'_, Message>, overlay_cursor: Point, viewport_size: Size, @@ -758,145 +1243,148 @@ pub(super) fn init_root_menu( bar_bounds: Rectangle, main_offset: f32, ) where - Renderer: renderer::Renderer, + Message: std::clone::Clone, { - let state = menu.tree.state.downcast_mut::(); - if !(state.menu_states.is_empty() && bar_bounds.contains(overlay_cursor)) { - return; - } - - for (i, (&root_bounds, mt)) in menu - .root_bounds_list - .iter() - .zip(menu.menu_roots.iter()) - .enumerate() - { - if mt.children.is_empty() { - continue; + menu.tree.inner.with_data_mut(|state| { + if !(state.menu_states.get(menu.depth).is_none() + && (!menu.is_overlay || bar_bounds.contains(overlay_cursor))) + { + return; } - if root_bounds.contains(overlay_cursor) { - let view_center = viewport_size.width * 0.5; - let rb_center = root_bounds.center_x(); + let active_roots = &state.active_root[..=menu.depth]; - state.horizontal_direction = if rb_center > view_center { - Direction::Negative - } else { - Direction::Positive - }; - - let aod = Aod { - horizontal: true, - vertical: true, - horizontal_overlap: true, - vertical_overlap: false, - horizontal_direction: state.horizontal_direction, - vertical_direction: state.vertical_direction, - horizontal_offset: 0.0, - vertical_offset: main_offset, - }; - - let menu_bounds = MenuBounds::new( - mt, - renderer, - menu.item_width, - menu.item_height, - viewport_size, - overlay_offset, - &aod, - menu.bounds_expand, - root_bounds, - &mut menu.tree.children[i].children, - ); - - state.active_root = Some(i); - state.menu_states.push(MenuState { - index: None, - scroll_offset: 0.0, - menu_bounds, + let mt = active_roots + .iter() + .skip(1) + .fold(&menu.menu_roots[active_roots[0]], |mt, next_active_root| { + &mt.children[*next_active_root] }); + let i = active_roots.last().unwrap(); + let root_bounds = menu.root_bounds_list[*i]; - // Hack to ensure menu opens properly - shell.invalidate_layout(); + assert!(!mt.children.is_empty(), "skipping menu with no children"); + let aod = Aod { + horizontal: true, + vertical: true, + horizontal_overlap: true, + vertical_overlap: false, + horizontal_direction: state.horizontal_direction, + vertical_direction: state.vertical_direction, + horizontal_offset: 0.0, + vertical_offset: main_offset, + }; + let menu_bounds = MenuBounds::new( + mt, + renderer, + menu.item_width, + menu.item_height, + viewport_size, + overlay_offset, + &aod, + menu.bounds_expand, + root_bounds, + // TODO how to select the tree for the popup + &mut state.tree.children[0].children, + menu.is_overlay, + ); - break; - } - } + let view_center = viewport_size.width * 0.5; + let rb_center = root_bounds.center_x(); + + state.horizontal_direction = if rb_center > view_center { + Direction::Negative + } else { + Direction::Positive + }; + + let ms = MenuState { + index: None, + scroll_offset: 0.0, + menu_bounds, + }; + state.menu_states.push(ms); + + // Hack to ensure menu opens properly + shell.invalidate_layout(); + }); } #[allow(clippy::too_many_arguments)] -fn process_menu_events<'b, Message, Renderer>( - tree: &'b mut Tree, - menu_roots: &'b mut [MenuTree<'_, Message, Renderer>], - event: event::Event, +fn process_menu_events( + menu: &mut Menu, + event: &event::Event, view_cursor: Cursor, - renderer: &Renderer, + renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, overlay_offset: Vector, -) -> event::Status -where - Renderer: renderer::Renderer, -{ - use event::Status; - - let state = tree.state.downcast_mut::(); - let Some(active_root) = state.active_root else { - return Status::Ignored; +) { + let my_state = &mut menu.tree; + let menu_roots = match &mut menu.menu_roots { + Cow::Borrowed(_) => panic!(), + Cow::Owned(o) => o.as_mut_slice(), }; + my_state.inner.with_data_mut(|state| { + if state.active_root.len() <= menu.depth { + return; + } - let indices = state.get_trimmed_indices().collect::>(); + let Some(hover) = state.menu_states.last_mut() else { + return; + }; - if indices.is_empty() { - return Status::Ignored; - } + let Some(hover_index) = hover.index else { + return; + }; - // get active item - let mt = indices - .iter() - .fold(&mut menu_roots[active_root], |mt, &i| &mut mt.children[i]); + let mt = state.active_root.iter().skip(1).fold( + // then use menu states for each open menu + &mut menu_roots[state.active_root[0]], + |mt, next_active_root| &mut mt.children[*next_active_root], + ); - // widget tree - let tree = &mut tree.children[active_root].children[mt.index]; + let mt = &mut mt.children[hover_index]; + let tree = &mut state.tree.children[state.active_root[0]].children[mt.index]; - // get layout - let last_ms = &state.menu_states[indices.len() - 1]; - let child_node = last_ms.layout_single( - overlay_offset, - last_ms.index.expect("missing index within menu state."), - renderer, - mt, - tree, - ); - let child_layout = Layout::new(&child_node); + // get layout + let child_node = hover.layout_single( + overlay_offset, + hover.index.expect("missing index within menu state."), + renderer, + mt, + tree, + ); + let child_layout = Layout::new(&child_node); - // process only the last widget - mt.item.as_widget_mut().on_event( - tree, - event, - child_layout, - view_cursor, - renderer, - clipboard, - shell, - &Rectangle::default(), - ) + // process only the last widget + mt.item.update( + tree, + event, + child_layout, + view_cursor, + renderer, + clipboard, + shell, + &Rectangle::default(), + ); + }); } -#[allow(unused_results)] -fn process_overlay_events( - menu: &mut Menu<'_, '_, Message, Renderer>, - renderer: &Renderer, +#[allow(unused_results, clippy::too_many_lines, clippy::too_many_arguments)] +fn process_overlay_events( + menu: &mut Menu, + renderer: &crate::Renderer, viewport_size: Size, overlay_offset: Vector, view_cursor: Cursor, overlay_cursor: Point, cross_offset: f32, -) -> event::Status + shell: &mut Shell<'_, Message>, +) -> Option<(usize, MenuState)> where - Renderer: renderer::Renderer, + Message: std::clone::Clone, { - use event::Status::{Captured, Ignored}; /* if no active root || pressed: return @@ -906,263 +1394,296 @@ where if active item is a menu: add menu // viewport space */ + let mut new_menu_root = None; - let state = menu.tree.state.downcast_mut::(); + menu.tree.inner.with_data_mut(|state| { - let Some(active_root) = state.active_root else { - if !menu.bar_bounds.contains(overlay_cursor) { - state.reset(); - } - return Ignored; - }; + /* When overlay is running, cursor_position in any widget method will go negative + but I still want Widget::draw() to react to cursor movement */ + state.view_cursor = view_cursor; - if state.pressed { - return Ignored; - } + // * remove invalid menus - /* When overlay is running, cursor_position in any widget method will go negative - but I still want Widget::draw() to react to cursor movement */ - state.view_cursor = view_cursor; - - // * remove invalid menus - let mut prev_bounds = std::iter::once(menu.bar_bounds) - .chain( - state.menu_states[..state.menu_states.len().saturating_sub(1)] - .iter() - .map(|ms| ms.menu_bounds.children_bounds), - ) - .collect::>(); - - if menu.close_condition.leave { - for i in (0..state.menu_states.len()).rev() { - let mb = &state.menu_states[i].menu_bounds; - - if mb.parent_bounds.contains(overlay_cursor) - || mb.children_bounds.contains(overlay_cursor) - || mb.offset_bounds.contains(overlay_cursor) - || (mb.check_bounds.contains(overlay_cursor) - && prev_bounds.iter().all(|pvb| !pvb.contains(overlay_cursor))) - { - break; - } - prev_bounds.pop(); - state.menu_states.pop(); - } - } else { - for i in (0..state.menu_states.len()).rev() { - let mb = &state.menu_states[i].menu_bounds; - - if mb.parent_bounds.contains(overlay_cursor) - || mb.children_bounds.contains(overlay_cursor) - || prev_bounds.iter().all(|pvb| !pvb.contains(overlay_cursor)) - { - break; - } - prev_bounds.pop(); - state.menu_states.pop(); - } - } - - // get indices - let indices = state - .menu_states - .iter() - .map(|ms| ms.index) - .collect::>(); - - // * update active item - let Some(last_menu_state) = state.menu_states.last_mut() else { - // no menus left - state.active_root = None; - - // keep state.open when the cursor is still inside the menu bar - // this allows the overlay to keep drawing when the cursor is - // moving aroung the menu bar - if !menu.bar_bounds.contains(overlay_cursor) { - state.open = false; - } - return Captured; - }; - - let last_menu_bounds = &last_menu_state.menu_bounds; - let last_parent_bounds = last_menu_bounds.parent_bounds; - let last_children_bounds = last_menu_bounds.children_bounds; - - if (!menu.menu_overlays_parent && last_parent_bounds.contains(overlay_cursor)) - // cursor is in the parent part - || !last_children_bounds.contains(overlay_cursor) - // cursor is outside - { - last_menu_state.index = None; - return Captured; - } - // cursor is in the children part - - // calc new index - let height_diff = (overlay_cursor.y - (last_children_bounds.y + last_menu_state.scroll_offset)) - .clamp(0.0, last_children_bounds.height - 0.001); - - let active_menu_root = &menu.menu_roots[active_root]; - - let active_menu = indices[0..indices.len().saturating_sub(1)] - .iter() - .fold(active_menu_root, |mt, i| { - &mt.children[i.expect("missing active child index in menu")] - }); - - let new_index = match menu.item_height { - ItemHeight::Uniform(u) => (height_diff / f32::from(u)).floor() as usize, - ItemHeight::Static(_) | ItemHeight::Dynamic(_) => { - let max_index = active_menu.children.len() - 1; - search_bound( - 0, - 0, - max_index, - height_diff, - &last_menu_bounds.child_positions, - &last_menu_bounds.child_sizes, + let mut prev_bounds = std::iter::once(menu.bar_bounds) + .chain( + if menu.is_overlay { + state.menu_states[..state.menu_states.len().saturating_sub(1)].iter() + } else { + state.menu_states[..menu.depth].iter() + } + .map(|s| s.menu_bounds.children_bounds), ) + .collect::>(); + + if menu.is_overlay && menu.close_condition.leave { + for i in (0..state.menu_states.len()).rev() { + let mb = &state.menu_states[i].menu_bounds; + + if mb.parent_bounds.contains(overlay_cursor) + || menu.is_overlay && mb.children_bounds.contains(overlay_cursor) + || mb.offset_bounds.contains(overlay_cursor) + || (mb.check_bounds.contains(overlay_cursor) + && prev_bounds.iter().all(|pvb| !pvb.contains(overlay_cursor))) + { + break; + } + prev_bounds.pop(); + state.active_root.pop(); + state.menu_states.pop(); + } + } else if menu.is_overlay { + for i in (0..state.menu_states.len()).rev() { + let mb = &state.menu_states[i].menu_bounds; + + if mb.parent_bounds.contains(overlay_cursor) + || mb.children_bounds.contains(overlay_cursor) + || prev_bounds.iter().all(|pvb| !pvb.contains(overlay_cursor)) + { + break; + } + prev_bounds.pop(); + state.active_root.pop(); + state.menu_states.pop(); + } } - }; - // set new index - last_menu_state.index = Some(new_index); + // * update active item + let menu_states_len = state.menu_states.len(); - // get new active item - let item = &active_menu.children[new_index]; + let Some(last_menu_state) = state.menu_states.get_mut(if menu.is_overlay { + menu_states_len.saturating_sub(1) + } else { + menu.depth + }) else { + if menu.is_overlay { + // no menus left + // TODO do we want to avoid this for popups? + // state.active_root.remove(menu.depth); - // * add new menu if the new item is a menu - if !item.children.is_empty() { - let item_position = Point::new( - 0.0, - last_menu_bounds.child_positions[new_index] + last_menu_state.scroll_offset, - ); - let item_size = last_menu_bounds.child_sizes[new_index]; - - // overlay space item bounds - let item_bounds = Rectangle::new(item_position, item_size) - + (last_menu_bounds.children_bounds.position() - Point::ORIGIN); - - let aod = Aod { - horizontal: true, - vertical: true, - horizontal_overlap: false, - vertical_overlap: true, - horizontal_direction: state.horizontal_direction, - vertical_direction: state.vertical_direction, - horizontal_offset: cross_offset, - vertical_offset: 0.0, + // keep state.open when the cursor is still inside the menu bar + // this allows the overlay to keep drawing when the cursor is + // moving aroung the menu bar + if !menu.bar_bounds.contains(overlay_cursor) { + state.open = false; + } + } + shell.capture_event(); + return new_menu_root; }; - state.menu_states.push(MenuState { - index: None, - scroll_offset: 0.0, - menu_bounds: MenuBounds::new( - item, - renderer, - menu.item_width, - menu.item_height, - viewport_size, - overlay_offset, - &aod, - menu.bounds_expand, - item_bounds, - &mut menu.tree.children[active_root].children, - ), - }); - } + let last_menu_bounds = &last_menu_state.menu_bounds; + let last_parent_bounds = last_menu_bounds.parent_bounds; + let last_children_bounds = last_menu_bounds.children_bounds; - Captured + if (menu.is_overlay && !menu.menu_overlays_parent && last_parent_bounds.contains(overlay_cursor)) + // cursor is in the parent part + || menu.is_overlay && !last_children_bounds.contains(overlay_cursor) + // cursor is outside + { + + last_menu_state.index = None; + shell.capture_event(); + return new_menu_root; + } + + // calc new index + let height_diff = (overlay_cursor.y + - (last_children_bounds.y + last_menu_state.scroll_offset)) + .clamp(0.0, last_children_bounds.height - 0.001); + + let active_root = if menu.is_overlay { + &state.active_root + } else { + &state.active_root[..=menu.depth] + }; + + if state.pressed { + return new_menu_root; + } + let roots = active_root.iter().skip(1).fold( + &menu.menu_roots[active_root[0]].children, + |mt, next_active_root| &mt[*next_active_root].children, + ); + let tree = &mut state.tree.children[active_root[0]].children; + + let active_menu: &Vec> = roots; + let new_index = match menu.item_height { + ItemHeight::Uniform(u) => (height_diff / f32::from(u)).floor() as usize, + ItemHeight::Static(_) | ItemHeight::Dynamic(_) => { + let max_index = active_menu.len() - 1; + search_bound( + 0, + 0, + max_index, + height_diff, + &last_menu_bounds.child_positions, + &last_menu_bounds.child_sizes, + ) + } + }; + + let remove = last_menu_state + .index + .as_ref() + .is_some_and(|i| *i != new_index && !active_menu[*i].children.is_empty()); + + #[cfg(all(feature = "multi-window", feature = "wayland",target_os = "linux", feature = "winit", feature = "surface-message"))] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && remove { + if let Some(id) = state.popup_id.remove(&menu.window_id) { + state.active_root.truncate(menu.depth + 1); + shell.publish((menu.on_surface_action.as_ref().unwrap())({ + crate::surface::action::destroy_popup(id) + })); + } + } + let item = &active_menu[new_index]; + // set new index + let old_index = last_menu_state.index.replace(new_index); + + // get new active item + // * add new menu if the new item is a menu + if !item.children.is_empty() && old_index.is_none_or(|i| i != new_index) { + let item_position = Point::new( + 0.0, + last_menu_bounds.child_positions[new_index] + last_menu_state.scroll_offset, + ); + let item_size = last_menu_bounds.child_sizes[new_index]; + + // overlay space item bounds + let item_bounds = Rectangle::new(item_position, item_size) + + (last_menu_bounds.children_bounds.position() - Point::ORIGIN); + + let aod = Aod { + horizontal: true, + vertical: true, + horizontal_overlap: false, + vertical_overlap: true, + horizontal_direction: state.horizontal_direction, + vertical_direction: state.vertical_direction, + horizontal_offset: cross_offset, + vertical_offset: 0.0, + }; + let ms = MenuState { + index: None, + scroll_offset: 0.0, + menu_bounds: MenuBounds::new( + item, + renderer, + menu.item_width, + menu.item_height, + viewport_size, + overlay_offset, + &aod, + menu.bounds_expand, + item_bounds, + tree, + menu.is_overlay, + ), + }; + + new_menu_root = Some((new_index, ms.clone())); + if menu.is_overlay { + state.active_root.push(new_index); + } else { + state.menu_states.truncate(menu.depth + 1); + } + state.menu_states.push(ms); + } else if !menu.is_overlay && remove { + state.menu_states.truncate(menu.depth + 1); + } + + shell.capture_event(); + new_menu_root + }) } -fn process_scroll_events( - menu: &mut Menu<'_, '_, Message, Renderer>, +fn process_scroll_events( + menu: &mut Menu<'_, Message>, + shell: &mut Shell<'_, Message>, delta: mouse::ScrollDelta, overlay_cursor: Point, viewport_size: Size, overlay_offset: Vector, -) -> event::Status -where - Renderer: renderer::Renderer, +) where + Message: Clone, { use event::Status::{Captured, Ignored}; use mouse::ScrollDelta; - let state = menu.tree.state.downcast_mut::(); + menu.tree.inner.with_data_mut(|state| { + let delta_y = match delta { + ScrollDelta::Lines { y, .. } => y * 60.0, + ScrollDelta::Pixels { y, .. } => y, + }; - let delta_y = match delta { - ScrollDelta::Lines { y, .. } => y * 60.0, - ScrollDelta::Pixels { y, .. } => y, - }; + let calc_offset_bounds = |menu_state: &MenuState, viewport_size: Size| -> (f32, f32) { + // viewport space children bounds + let children_bounds = menu_state.menu_bounds.children_bounds + overlay_offset; - let calc_offset_bounds = |menu_state: &MenuState, viewport_size: Size| -> (f32, f32) { - // viewport space children bounds - let children_bounds = menu_state.menu_bounds.children_bounds + overlay_offset; + let max_offset = (0.0 - children_bounds.y).max(0.0); + let min_offset = + (viewport_size.height - (children_bounds.y + children_bounds.height)).min(0.0); + (max_offset, min_offset) + }; - let max_offset = (0.0 - children_bounds.y).max(0.0); - let min_offset = - (viewport_size.height - (children_bounds.y + children_bounds.height)).min(0.0); - (max_offset, min_offset) - }; + // update + if state.menu_states.is_empty() { + return; + } else if state.menu_states.len() == 1 { + let last_ms = &mut state.menu_states[0]; - // update - if state.menu_states.is_empty() { - return Ignored; - } else if state.menu_states.len() == 1 { - let last_ms = &mut state.menu_states[0]; - - if last_ms.index.is_none() { - return Captured; - } - - let (max_offset, min_offset) = calc_offset_bounds(last_ms, viewport_size); - last_ms.scroll_offset = (last_ms.scroll_offset + delta_y).clamp(min_offset, max_offset); - } else { - // >= 2 - let max_index = state.menu_states.len() - 1; - let last_two = &mut state.menu_states[max_index - 1..=max_index]; - - if last_two[1].index.is_some() { - // scroll the last one - let (max_offset, min_offset) = calc_offset_bounds(&last_two[1], viewport_size); - last_two[1].scroll_offset = - (last_two[1].scroll_offset + delta_y).clamp(min_offset, max_offset); - } else { - if !last_two[0] - .menu_bounds - .children_bounds - .contains(overlay_cursor) - { - return Captured; + if last_ms.index.is_none() { + return; } - // scroll the second last one - let (max_offset, min_offset) = calc_offset_bounds(&last_two[0], viewport_size); - let scroll_offset = (last_two[0].scroll_offset + delta_y).clamp(min_offset, max_offset); - let clamped_delta_y = scroll_offset - last_two[0].scroll_offset; - last_two[0].scroll_offset = scroll_offset; + let (max_offset, min_offset) = calc_offset_bounds(last_ms, viewport_size); + last_ms.scroll_offset = (last_ms.scroll_offset + delta_y).clamp(min_offset, max_offset); + } else { + // >= 2 + let max_index = state.menu_states.len() - 1; + let last_two = &mut state.menu_states[max_index - 1..=max_index]; - // update the bounds of the last one - last_two[1].menu_bounds.parent_bounds.y += clamped_delta_y; - last_two[1].menu_bounds.children_bounds.y += clamped_delta_y; - last_two[1].menu_bounds.check_bounds.y += clamped_delta_y; + if last_two[1].index.is_some() { + // scroll the last one + let (max_offset, min_offset) = calc_offset_bounds(&last_two[1], viewport_size); + last_two[1].scroll_offset = + (last_two[1].scroll_offset + delta_y).clamp(min_offset, max_offset); + } else { + if !last_two[0] + .menu_bounds + .children_bounds + .contains(overlay_cursor) + { + shell.capture_event(); + return; + } + + // scroll the second last one + let (max_offset, min_offset) = calc_offset_bounds(&last_two[0], viewport_size); + let scroll_offset = + (last_two[0].scroll_offset + delta_y).clamp(min_offset, max_offset); + let clamped_delta_y = scroll_offset - last_two[0].scroll_offset; + last_two[0].scroll_offset = scroll_offset; + + // update the bounds of the last one + last_two[1].menu_bounds.parent_bounds.y += clamped_delta_y; + last_two[1].menu_bounds.children_bounds.y += clamped_delta_y; + last_two[1].menu_bounds.check_bounds.y += clamped_delta_y; + } } - } - Captured + shell.capture_event(); + }); } #[allow(clippy::pedantic)] /// Returns (children_size, child_positions, child_sizes) -fn get_children_layout( - menu_tree: &MenuTree<'_, Message, Renderer>, - renderer: &Renderer, +fn get_children_layout( + menu_tree: &MenuTree, + renderer: &crate::Renderer, item_width: ItemWidth, item_height: ItemHeight, tree: &mut [Tree], -) -> (Size, Vec, Vec) -where - Renderer: renderer::Renderer, -{ +) -> (Size, Vec, Vec) { let width = match item_width { ItemWidth::Uniform(u) => f32::from(u), ItemWidth::Static(s) => f32::from(menu_tree.width.unwrap_or(s)), @@ -1171,7 +1692,7 @@ where let child_sizes: Vec = match item_height { ItemHeight::Uniform(u) => { let count = menu_tree.children.len(); - (0..count).map(|_| Size::new(width, f32::from(u))).collect() + vec![Size::new(width, f32::from(u)); count] } ItemHeight::Static(s) => menu_tree .children @@ -1182,37 +1703,39 @@ where .children .iter() .map(|mt| { - let w = mt.item.as_widget(); - match w.size().height { - Length::Fixed(f) => Size::new(width, f), - Length::Shrink => { - let l_height = w - .layout( - &mut tree[mt.index], - renderer, - &Limits::new(Size::ZERO, Size::new(width, f32::MAX)), - ) - .size() - .height; + mt.item + .element + .with_data_mut(|w| match w.as_widget_mut().size().height { + Length::Fixed(f) => Size::new(width, f), + Length::Shrink => { + let l_height = w + .as_widget_mut() + .layout( + &mut tree[mt.index], + renderer, + &Limits::new(Size::ZERO, Size::new(width, f32::MAX)), + ) + .size() + .height; - let height = if (f32::MAX - l_height) < 0.001 { - f32::from(d) - } else { - l_height - }; + let height = if (f32::MAX - l_height) < 0.001 { + f32::from(d) + } else { + l_height + }; - Size::new(width, height) - } - _ => mt.height.map_or_else( - || Size::new(width, f32::from(d)), - |h| Size::new(width, f32::from(h)), - ), - } + Size::new(width, height) + } + _ => mt.height.map_or_else( + || Size::new(width, f32::from(d)), + |h| Size::new(width, f32::from(h)), + ), + }) }) .collect(), }; - let max_index = menu_tree.children.len() - 1; + let max_index = menu_tree.children.len().saturating_sub(1); let child_positions: Vec = std::iter::once(0.0) .chain(child_sizes[0..max_index].iter().scan(0.0, |acc, x| { *acc += x.height; diff --git a/src/widget/menu/menu_tree.rs b/src/widget/menu/menu_tree.rs index 65ebbd9c..41cf1dff 100644 --- a/src/widget/menu/menu_tree.rs +++ b/src/widget/menu/menu_tree.rs @@ -6,12 +6,14 @@ use std::borrow::Cow; use std::collections::HashMap; use std::rc::Rc; -use iced_widget::core::{renderer, Element}; +use iced::advanced::widget::text::Style as TextStyle; +use iced_widget::core::{Element, renderer}; -use crate::iced_core::{Alignment, Length}; use crate::widget::menu::action::MenuAction; use crate::widget::menu::key_bind::KeyBind; +use crate::widget::{Button, RcElementWrapper, icon}; use crate::{theme, widget}; +use iced_core::{Alignment, Length}; /// Nested menu is essentially a tree of items, a menu is a collection of items /// a menu itself can also be an item of another menu. @@ -22,27 +24,25 @@ use crate::{theme, widget}; /// but there's no need to explicitly distinguish them here, if a menu tree /// has children, it's a menu, otherwise it's an item #[allow(missing_debug_implementations)] -pub struct MenuTree<'a, Message, Renderer = crate::Renderer> { +#[derive(Clone)] +pub struct MenuTree { /// The menu tree will be flatten into a vector to build a linear widget tree, /// the `index` field is the index of the item in that vector pub(crate) index: usize, /// The item of the menu tree - pub(crate) item: Element<'a, Message, crate::Theme, Renderer>, + pub(crate) item: RcElementWrapper, /// The children of the menu tree - pub(crate) children: Vec>, + pub(crate) children: Vec>, /// The width of the menu tree pub(crate) width: Option, /// The height of the menu tree pub(crate) height: Option, } -impl<'a, Message, Renderer> MenuTree<'a, Message, Renderer> -where - Renderer: renderer::Renderer, -{ +impl MenuTree { /// Create a new menu tree from a widget - pub fn new(item: impl Into>) -> Self { + pub fn new(item: impl Into>) -> Self { Self { index: 0, item: item.into(), @@ -54,8 +54,8 @@ where /// Create a menu tree from a widget and a vector of sub trees pub fn with_children( - item: impl Into>, - children: Vec>>, + item: impl Into>, + children: Vec>>, ) -> Self { Self { index: 0, @@ -91,7 +91,7 @@ where /// Set the index of each item pub(crate) fn set_index(&mut self) { /// inner counting function. - fn rec(mt: &mut MenuTree<'_, Message, Renderer>, count: &mut usize) { + fn rec(mt: &mut MenuTree, count: &mut usize) { // keep items under the same menu line up mt.children.iter_mut().for_each(|c| { c.index = *count; @@ -108,11 +108,11 @@ where } /// Flatten the menu tree - pub(crate) fn flattern(&'a self) -> Vec<&Self> { + pub(crate) fn flattern(&self) -> Vec<&Self> { /// Inner flattening function - fn rec<'a, Message, Renderer>( - mt: &'a MenuTree<'a, Message, Renderer>, - flat: &mut Vec<&MenuTree<'a, Message, Renderer>>, + fn rec<'a, Message: Clone + 'static>( + mt: &'a MenuTree, + flat: &mut Vec<&'a MenuTree>, ) { mt.children.iter().for_each(|c| { flat.push(c); @@ -131,31 +131,31 @@ where } } -impl<'a, Message, Renderer> From> - for MenuTree<'a, Message, Renderer> -where - Renderer: renderer::Renderer, -{ - fn from(value: Element<'a, Message, crate::Theme, Renderer>) -> Self { - Self::new(value) +impl From> for MenuTree { + fn from(value: crate::Element<'static, Message>) -> Self { + Self::new(RcElementWrapper::new(value)) } } -pub fn menu_button<'a, Message: 'a>( +pub fn menu_button<'a, Message>( children: Vec>, -) -> crate::widget::Button<'a, Message> { +) -> crate::widget::Button<'a, Message> +where + Message: std::clone::Clone + 'a, +{ widget::button::custom( - widget::Row::with_children(children) - .align_items(Alignment::Center) + widget::Row::from_vec(children) + .align_y(Alignment::Center) .height(Length::Fill) .width(Length::Fill), ) .height(Length::Fixed(36.0)) .padding([4, 16]) .width(Length::Fill) - .style(theme::Button::MenuItem) + .class(theme::Button::MenuItem) } +#[derive(Clone)] /// Represents a menu item that performs an action when selected or a separator between menu items. /// /// - `Action` - Represents a menu item that performs an action when selected. @@ -171,11 +171,11 @@ pub fn menu_button<'a, Message: 'a>( /// - `Divider` - Represents a divider between menu items. pub enum MenuItem>> { /// Represents a button menu item. - Button(L, A), + Button(L, Option, A), /// Represents a button menu item that is disabled. - ButtonDisabled(L, A), + ButtonDisabled(L, Option, A), /// Represents a checkbox menu item. - CheckBox(L, bool, A), + CheckBox(L, Option, bool, A), /// Represents a folder menu item. Folder(L, Vec>), /// Represents a divider between menu items. @@ -191,14 +191,14 @@ pub enum MenuItem>> { /// - A button for the root menu item. pub fn menu_root<'a, Message, Renderer: renderer::Renderer>( label: impl Into> + 'a, -) -> iced::Element<'a, Message, crate::Theme, Renderer> +) -> Button<'a, Message> where Element<'a, Message, crate::Theme, Renderer>: From>, + Message: std::clone::Clone + 'a, { widget::button::custom(widget::text(label)) .padding([4, 12]) - .style(theme::Button::MenuRoot) - .into() + .class(theme::Button::MenuRoot) } /// Create a list of menu items from a vector of `MenuItem`. @@ -211,19 +211,15 @@ where /// /// # Returns /// - A vector of `MenuTree`. +#[must_use] pub fn menu_items< - 'a, A: MenuAction, L: Into> + 'static, - Message: 'a, - Renderer: renderer::Renderer + 'a, + Message: 'static + std::clone::Clone, >( key_binds: &HashMap, children: Vec>, -) -> Vec> -where - Element<'a, Message, crate::Theme, Renderer>: From>, -{ +) -> Vec> { fn find_key(action: &A, key_binds: &HashMap) -> String { for (key_bind, key_action) in key_binds { if action == key_action { @@ -233,6 +229,15 @@ where String::new() } + fn key_style(theme: &crate::Theme) -> TextStyle { + let mut color = theme.cosmic().background.component.on; + color.alpha *= 0.75; + TextStyle { + color: Some(color.into()), + } + } + let key_class = theme::Text::Custom(key_style); + let size = children.len(); children @@ -240,84 +245,154 @@ where .enumerate() .flat_map(|(i, item)| { let mut trees = vec![]; + let spacing = crate::theme::spacing(); + match item { - MenuItem::Button(label, action) => { + MenuItem::Button(label, icon, action) => { + let l: Cow<'static, str> = label.into(); let key = find_key(&action, key_binds); - let menu_button = menu_button(vec![ - widget::text(label).into(), - widget::horizontal_space(Length::Fill).into(), - widget::text(key).into(), - ]) - .on_press(action.message()); + let mut items = vec![ + widget::text(l) + .ellipsize(iced_core::text::Ellipsize::Middle( + iced_core::text::EllipsizeHeightLimit::Lines(1), + )) + .into(), + widget::space::horizontal().into(), + widget::text(key) + .class(key_class) + .ellipsize(iced_core::text::Ellipsize::Middle( + iced_core::text::EllipsizeHeightLimit::Lines(1), + )) + .into(), + ]; - trees.push(MenuTree::::new(menu_button)); - } - MenuItem::ButtonDisabled(label, action) => { - let key = find_key(&action, key_binds); - let menu_button = menu_button(vec![ - widget::text(label).into(), - widget::horizontal_space(Length::Fill).into(), - widget::text(key).into(), - ]); + if let Some(icon) = icon { + items.insert(0, widget::icon::icon(icon).size(14).into()); + items.insert( + 1, + widget::space::horizontal().width(spacing.space_xxs).into(), + ); + } - trees.push(MenuTree::::new(menu_button)); + let menu_button = menu_button(items).on_press(action.message()); + + trees.push(MenuTree::::from(Element::from(menu_button))); } - MenuItem::CheckBox(label, value, action) => { + MenuItem::ButtonDisabled(label, icon, action) => { + let l: Cow<'static, str> = label.into(); + let key = find_key(&action, key_binds); - trees.push(MenuTree::new( - menu_button(vec![ - if value { - widget::icon::from_name("object-select-symbolic") - .size(16) - .icon() - .style(theme::Svg::Custom(Rc::new(|theme| { - crate::iced_style::svg::Appearance { - color: Some(theme.cosmic().accent_color().into()), - } - }))) - .width(Length::Fixed(16.0)) - .into() - } else { - widget::Space::with_width(Length::Fixed(16.0)).into() - }, - widget::Space::with_width(Length::Fixed(8.0)).into(), - widget::text(label) - .horizontal_alignment(iced::alignment::Horizontal::Left) - .into(), - widget::horizontal_space(Length::Fill).into(), - widget::text(key).into(), - ]) - .on_press(action.message()), - )); + + let mut items = vec![ + widget::text(l) + .ellipsize(iced_core::text::Ellipsize::Middle( + iced_core::text::EllipsizeHeightLimit::Lines(1), + )) + .into(), + widget::space::horizontal().into(), + widget::text(key) + .ellipsize(iced_core::text::Ellipsize::Middle( + iced_core::text::EllipsizeHeightLimit::Lines(1), + )) + .class(key_class) + .into(), + ]; + + if let Some(icon) = icon { + items.insert(0, widget::icon::icon(icon).size(14).into()); + items.insert( + 1, + widget::space::horizontal().width(spacing.space_xxs).into(), + ); + } + + let menu_button = menu_button(items); + + trees.push(MenuTree::::from(Element::from(menu_button))); } - MenuItem::Folder(label, children) => { - trees.push(MenuTree::::with_children( - menu_button(vec![ - widget::text(label).into(), - widget::horizontal_space(Length::Fill).into(), - widget::icon::from_name("pan-end-symbolic") + MenuItem::CheckBox(label, icon, value, action) => { + let key = find_key(&action, key_binds); + let mut items = vec![ + if value { + widget::icon::from_name("object-select-symbolic") .size(16) .icon() - .into(), - ]) - .style( - // Menu folders have no on_press so they take on the disabled style by default - if children.is_empty() { - // This will make the folder use the disabled style if it has no children - theme::Button::MenuItem - } else { - // This will make the folder use the enabled style if it has children - theme::Button::MenuFolder - }, - ), + .class(theme::Svg::Custom(Rc::new(|theme| { + iced_widget::svg::Style { + color: Some(theme.cosmic().accent_text_color().into()), + } + }))) + .width(Length::Fixed(16.0)) + .into() + } else { + widget::space::horizontal() + .width(Length::Fixed(16.0)) + .into() + }, + widget::space::horizontal().width(spacing.space_xxs).into(), + widget::text(label) + .ellipsize(iced_core::text::Ellipsize::Middle( + iced_core::text::EllipsizeHeightLimit::Lines(1), + )) + .align_x(iced::Alignment::Start) + .into(), + widget::space::horizontal().into(), + widget::text(key) + .class(key_class) + .ellipsize(iced_core::text::Ellipsize::Middle( + iced_core::text::EllipsizeHeightLimit::Lines(1), + )) + .into(), + ]; + + if let Some(icon) = icon { + items.insert( + 1, + widget::space::horizontal().width(spacing.space_xxs).into(), + ); + items.insert(2, widget::icon::icon(icon).size(14).into()); + } + + trees.push(MenuTree::from(Element::from( + menu_button(items).on_press(action.message()), + ))); + } + MenuItem::Folder(label, children) => { + let l: Cow<'static, str> = label.into(); + + trees.push(MenuTree::::with_children( + RcElementWrapper::new(crate::Element::from( + menu_button::<'static, _>(vec![ + widget::text(l.clone()) + .ellipsize(iced_core::text::Ellipsize::Middle( + iced_core::text::EllipsizeHeightLimit::Lines(1), + )) + .into(), + widget::space::horizontal().into(), + widget::icon::from_name("pan-end-symbolic") + .size(16) + .icon() + .into(), + ]) + .class( + // Menu folders have no on_press so they take on the disabled style by default + if children.is_empty() { + // This will make the folder use the disabled style if it has no children + theme::Button::MenuItem + } else { + // This will make the folder use the enabled style if it has children + theme::Button::MenuFolder + }, + ), + )), menu_items(key_binds, children), )); } MenuItem::Divider => { if i != size - 1 { - trees.push(MenuTree::::new( + trees.push(MenuTree::::from(Element::from( widget::divider::horizontal::light(), - )); + ))); } } } diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 60bfcc3d..f442b0da 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -18,13 +18,13 @@ //! //! const REPOSITORY: &str = "https://github.com/pop-os/libcosmic"; //! -//! let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing; +//! let cosmic_theme::Spacing { space_xxs, .. } = theme::spacing(); //! //! let link = widget::button::link(REPOSITORY) //! .on_press(Message::LaunchUrl(REPOSITORY)) //! .padding(0); //! -//! let content = widget::column() +//! let content = widget::column::with_capacity(3) //! .push(widget::icon::from_name("my-app-icon")) //! .push(widget::text::title3("My App Name")) //! .push(link) @@ -48,52 +48,66 @@ // Re-exports from Iced #[doc(inline)] -pub use iced::widget::{canvas, Canvas}; +pub use iced::widget::{Canvas, canvas}; #[doc(inline)] -pub use iced::widget::{checkbox, Checkbox}; +pub use iced::widget::{Checkbox, checkbox}; #[doc(inline)] -pub use iced::widget::{combo_box, ComboBox}; +pub use iced::widget::{Column, column}; #[doc(inline)] -pub use iced::widget::{container, Container}; +pub use iced::widget::{ComboBox, combo_box}; #[doc(inline)] -pub use iced::widget::{horizontal_space, space, vertical_space, Space}; +pub use iced::widget::{Container, container}; #[doc(inline)] -pub use iced::widget::{image, Image}; +pub use iced::widget::{Space, space}; #[doc(inline)] -pub use iced::widget::{lazy, Lazy}; +pub use iced::widget::{Image, image}; #[doc(inline)] -pub use iced::widget::{mouse_area, MouseArea}; +pub use iced::widget::{Lazy, lazy}; #[doc(inline)] -pub use iced::widget::{pane_grid, PaneGrid}; +pub use iced::widget::{MouseArea, mouse_area}; #[doc(inline)] -pub use iced::widget::{progress_bar, ProgressBar}; +pub use iced::widget::{PaneGrid, pane_grid}; #[doc(inline)] -pub use iced::widget::{responsive, Responsive}; +pub use iced::widget::{Responsive, responsive}; #[doc(inline)] -pub use iced::widget::{slider, vertical_slider, Slider, VerticalSlider}; +pub use iced::widget::{Row, row}; #[doc(inline)] -pub use iced::widget::{svg, Svg}; +pub use iced::widget::{Slider, VerticalSlider, slider, vertical_slider}; #[doc(inline)] -pub use iced::widget::{text_editor, TextEditor}; +pub use iced::widget::{Svg, svg}; + +#[doc(inline)] +pub use iced::widget::{TextEditor, text_editor}; #[doc(inline)] pub use iced_core::widget::{Id, Operation, Widget}; pub mod aspect_ratio; +#[cfg(feature = "autosize")] +pub mod autosize; + +pub(crate) mod responsive_container; + +#[cfg(feature = "surface-message")] +mod responsive_menu_bar; +#[cfg(feature = "surface-message")] +#[doc(inline)] +pub use responsive_menu_bar::{ResponsiveMenuBar, responsive_menu_bar}; + pub mod button; #[doc(inline)] pub use button::{Button, IconButton, LinkButton, TextButton}; @@ -102,7 +116,7 @@ pub(crate) mod common; pub mod calendar; #[doc(inline)] -pub use calendar::{calendar, Calendar}; +pub use calendar::{Calendar, calendar}; pub mod card; #[doc(inline)] @@ -116,137 +130,115 @@ pub use color_picker::{ColorPicker, ColorPickerModel}; #[doc(inline)] pub use iced::widget::qr_code; +mod cards; +#[doc(inline)] +pub use cards::cards; + pub mod context_drawer; #[doc(inline)] -pub use context_drawer::{context_drawer, ContextDrawer}; - -#[doc(inline)] -pub use column::{column, Column}; -pub mod column { - //! A container which aligns its children in a column. - - pub type Column<'a, Message> = iced::widget::Column<'a, Message, crate::Theme, crate::Renderer>; - - #[must_use] - /// A container which aligns its children in a column. - pub fn column<'a, Message>() -> Column<'a, Message> { - Column::new() - } - - #[must_use] - /// A pre-allocated [`column`]. - pub fn with_capacity<'a, Message>(capacity: usize) -> Column<'a, Message> { - Column::with_children(Vec::with_capacity(capacity)) - } - - #[must_use] - /// A [`column`] that will be assigned a [`Vec`] of children. - pub fn with_children(children: Vec>) -> Column { - Column::with_children(children) - } -} +pub use context_drawer::{ContextDrawer, context_drawer}; pub mod layer_container; #[doc(inline)] -pub use layer_container::{layer_container, LayerContainer}; +pub use layer_container::{LayerContainer, layer_container}; pub mod context_menu; #[doc(inline)] -pub use context_menu::{context_menu, ContextMenu}; +pub use context_menu::{ContextMenu, context_menu}; pub mod dialog; #[doc(inline)] -pub use dialog::{dialog, Dialog}; +pub use dialog::{Dialog, dialog}; /// An element to distinguish a boundary between two elements. pub mod divider { /// Horizontal variant of a divider. pub mod horizontal { - use iced::widget::{horizontal_rule, Rule}; + use iced::{widget::Rule, widget::rule}; /// Horizontal divider with default thickness #[must_use] - pub fn default() -> Rule { - horizontal_rule(1).style(crate::theme::Rule::Default) + pub fn default<'a>() -> Rule<'a, crate::Theme> { + rule::horizontal(1).class(crate::theme::Rule::Default) } /// Horizontal divider with light thickness #[must_use] - pub fn light() -> Rule { - horizontal_rule(1).style(crate::theme::Rule::LightDivider) + pub fn light<'a>() -> Rule<'a, crate::Theme> { + rule::horizontal(1).class(crate::theme::Rule::LightDivider) } /// Horizontal divider with heavy thickness. #[must_use] - pub fn heavy() -> Rule { - horizontal_rule(4).style(crate::theme::Rule::HeavyDivider) + pub fn heavy<'a>() -> Rule<'a, crate::Theme> { + rule::horizontal(4).class(crate::theme::Rule::HeavyDivider) } } /// Vertical variant of a divider. pub mod vertical { - use iced::widget::{vertical_rule, Rule}; + use iced::widget::{Rule, rule}; /// Vertical divider with default thickness #[must_use] - pub fn default() -> Rule { - vertical_rule(1).style(crate::theme::Rule::Default) + pub fn default<'a>() -> Rule<'a, crate::Theme> { + rule::vertical(1).class(crate::theme::Rule::Default) } /// Vertical divider with light thickness #[must_use] - pub fn light() -> Rule { - vertical_rule(4).style(crate::theme::Rule::LightDivider) + pub fn light<'a>() -> Rule<'a, crate::Theme> { + rule::vertical(4).class(crate::theme::Rule::LightDivider) } /// Vertical divider with heavy thickness. #[must_use] - pub fn heavy() -> Rule { - vertical_rule(10).style(crate::theme::Rule::HeavyDivider) + pub fn heavy<'a>() -> Rule<'a, crate::Theme> { + rule::vertical(10).class(crate::theme::Rule::HeavyDivider) } } } pub mod dnd_destination; #[doc(inline)] -pub use dnd_destination::{dnd_destination, DndDestination}; +pub use dnd_destination::{DndDestination, dnd_destination}; pub mod dnd_source; #[doc(inline)] -pub use dnd_source::{dnd_source, DndSource}; +pub use dnd_source::{DndSource, dnd_source}; pub mod dropdown; #[doc(inline)] -pub use dropdown::{dropdown, Dropdown}; +pub use dropdown::{Dropdown, dropdown}; pub mod flex_row; #[doc(inline)] -pub use flex_row::{flex_row, FlexRow}; +pub use flex_row::{FlexRow, flex_row}; pub mod grid; #[doc(inline)] -pub use grid::{grid, Grid}; +pub use grid::{Grid, grid}; mod header_bar; #[doc(inline)] -pub use header_bar::{header_bar, HeaderBar}; +pub use header_bar::{HeaderBar, header_bar}; pub mod icon; #[doc(inline)] -pub use icon::{icon, Icon}; +pub use icon::{Icon, icon}; pub mod id_container; #[doc(inline)] -pub use id_container::{id_container, IdContainer}; +pub use id_container::{IdContainer, id_container}; #[cfg(feature = "animated-image")] pub mod frames; -pub use taffy::JustifyContent; +pub use taffy::{JustifyContent, JustifyItems}; pub mod list; #[doc(inline)] -pub use list::{list_column, ListColumn}; +pub use list::{ListColumn, list_column}; pub mod menu; @@ -256,51 +248,30 @@ pub use nav_bar::{nav_bar, nav_bar_dnd}; pub mod nav_bar_toggle; #[doc(inline)] -pub use nav_bar_toggle::{nav_bar_toggle, NavBarToggle}; +pub use nav_bar_toggle::{NavBarToggle, nav_bar_toggle}; pub mod popover; #[doc(inline)] -pub use popover::{popover, Popover}; +pub use popover::{Popover, popover}; + +pub mod progress_bar; +#[doc(inline)] +pub use progress_bar::{ + circular, circular::Circular, determinate_circular, determinate_linear, indeterminate_circular, + indeterminate_linear, linear, linear::Linear, style, +}; pub mod radio; #[doc(inline)] -pub use radio::{radio, Radio}; +pub use radio::{Radio, radio}; pub mod rectangle_tracker; #[doc(inline)] -pub use rectangle_tracker::{rectangle_tracker, RectangleTracker}; +pub use rectangle_tracker::{RectangleTracker, rectangle_tracking_container}; +pub mod scrollable; #[doc(inline)] -pub use row::{row, Row}; - -pub mod row { - //! A container which aligns its children in a row. - - pub type Row<'a, Message> = iced::widget::Row<'a, Message, crate::Theme, crate::Renderer>; - - #[must_use] - /// A container which aligns its children in a row. - pub fn row<'a, Message>() -> Row<'a, Message> { - Row::new() - } - - #[must_use] - /// A pre-allocated [`row`]. - pub fn with_capacity<'a, Message>(capacity: usize) -> Row<'a, Message> { - Row::with_children(Vec::with_capacity(capacity)) - } - - #[must_use] - /// A [`row`] that will be assigned a [`Vec`] of children. - pub fn with_children(children: Vec>) -> Row { - Row::with_children(children) - } -} - -mod scrollable; -#[doc(inline)] -pub use scrollable::*; - +pub use scrollable::scrollable; pub mod segmented_button; pub mod segmented_control; @@ -308,33 +279,40 @@ pub mod settings; pub mod spin_button; #[doc(inline)] -pub use spin_button::{spin_button, SpinButton}; +pub use spin_button::{SpinButton, spin_button, vertical as vertical_spin_button}; pub mod tab_bar; +pub mod table; +#[doc(inline)] +pub use table::{compact_table, table}; + pub mod text; #[doc(inline)] -pub use text::{text, Text}; +pub use text::{Text, text}; pub mod text_input; #[doc(inline)] pub use text_input::{ - editable_input, inline_input, search_input, secure_input, text_input, TextInput, + TextInput, editable_input, inline_input, search_input, secure_input, text_input, }; pub mod toaster; #[doc(inline)] -pub use toaster::{toaster, Toast, ToastId, Toasts}; +pub use toaster::{Toast, ToastId, Toasts, toaster}; mod toggler; #[doc(inline)] -pub use toggler::toggler; +pub use toggler::{Toggler, toggler}; #[doc(inline)] -pub use tooltip::{tooltip, Tooltip}; +pub use tooltip::{Tooltip, tooltip}; + +#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +pub mod wayland; + pub mod tooltip { use crate::Element; - use std::borrow::Cow; pub use iced::widget::tooltip::Position; @@ -343,13 +321,13 @@ pub mod tooltip { pub fn tooltip<'a, Message>( content: impl Into>, - tooltip: impl Into>, + tooltip: impl Into>, position: Position, ) -> Tooltip<'a, Message> { - let xxs = crate::theme::active().cosmic().space_xxs(); + let xxs = crate::theme::spacing().space_xxs; Tooltip::new(content, tooltip, position) - .style(crate::theme::Container::Tooltip) + .class(crate::theme::Container::Tooltip) .padding(xxs) .gap(1) } @@ -358,3 +336,17 @@ pub mod tooltip { pub mod warning; #[doc(inline)] pub use warning::*; + +pub mod wrapper; +#[doc(inline)] +pub use wrapper::*; + +#[cfg(feature = "markdown")] +#[doc(inline)] +pub use iced::widget::markdown; + +#[cfg(feature = "about")] +pub mod about; +#[cfg(feature = "about")] +#[doc(inline)] +pub use about::about; diff --git a/src/widget/nav_bar.rs b/src/widget/nav_bar.rs index 246293cc..ad6f9206 100644 --- a/src/widget/nav_bar.rs +++ b/src/widget/nav_bar.rs @@ -7,13 +7,13 @@ use apply::Apply; use iced::{ - clipboard::{dnd::DndAction, mime::AllowedMimeTypes}, Background, Length, + clipboard::{dnd::DndAction, mime::AllowedMimeTypes}, }; use iced_core::{Border, Color, Shadow}; -use crate::widget::{container, menu, scrollable, segmented_button, Container, Icon}; -use crate::{theme, Theme}; +use crate::widget::{Container, Icon, container, menu, scrollable, segmented_button}; +use crate::{Theme, theme}; use super::dnd_destination::DragId; @@ -26,7 +26,7 @@ pub type Model = segmented_button::SingleSelectModel; pub fn nav_bar( model: &segmented_button::SingleSelectModel, on_activate: fn(segmented_button::Entity) -> Message, -) -> NavBar { +) -> NavBar<'_, Message> { NavBar { segmented_button: segmented_button::vertical(model).on_activate(on_activate), } @@ -41,7 +41,7 @@ pub fn nav_bar_dnd( on_dnd_leave: impl Fn(segmented_button::Entity) -> Message + 'static, on_dnd_drop: impl Fn(segmented_button::Entity, Option, DndAction) -> Message + 'static, id: DragId, -) -> NavBar +) -> NavBar<'_, Message> where Message: Clone + 'static, { @@ -62,16 +62,19 @@ pub struct NavBar<'a, Message> { } impl<'a, Message: Clone + 'static> NavBar<'a, Message> { + #[inline] pub fn close_icon(mut self, close_icon: Icon) -> Self { self.segmented_button = self.segmented_button.close_icon(close_icon); self } - pub fn context_menu(mut self, context_menu: Option>>) -> Self { + #[inline] + pub fn context_menu(mut self, context_menu: Option>>) -> Self { self.segmented_button = self.segmented_button.context_menu(context_menu); self } + #[inline] pub fn drag_id(mut self, id: DragId) -> Self { self.segmented_button = self.segmented_button.drag_id(id); self @@ -79,6 +82,7 @@ impl<'a, Message: Clone + 'static> NavBar<'a, Message> { /// Pre-convert this widget into the [`Container`] widget that it becomes. #[must_use] + #[inline] pub fn into_container(self) -> Container<'a, Message, crate::Theme, crate::Renderer> { Container::from(self) } @@ -145,13 +149,15 @@ impl<'a, Message: Clone + 'static> From> .button_padding([space_s, space_xxs, space_s, space_xxs]) .button_spacing(space_xxs) .spacing(space_xxs) - .style(crate::theme::SegmentedButton::TabBar) - .apply(scrollable) - .height(Length::Fill) + .style(crate::theme::SegmentedButton::NavBar) .apply(container) .padding(space_xxs) + .apply(scrollable) + .class(crate::style::iced::Scrollable::Minimal) .height(Length::Fill) - .style(theme::Container::custom(nav_bar_style)) + .apply(container) + .height(Length::Fill) + .class(theme::Container::custom(nav_bar_style)) } } @@ -162,9 +168,9 @@ impl<'a, Message: Clone + 'static> From> for crate::Element< } #[must_use] -pub fn nav_bar_style(theme: &Theme) -> iced_style::container::Appearance { +pub fn nav_bar_style(theme: &Theme) -> iced_widget::container::Style { let cosmic = &theme.cosmic(); - iced_style::container::Appearance { + iced_widget::container::Style { icon_color: Some(cosmic.on_bg_color().into()), text_color: Some(cosmic.on_bg_color().into()), background: Some(Background::Color(cosmic.primary.base.into())), @@ -174,5 +180,6 @@ pub fn nav_bar_style(theme: &Theme) -> iced_style::container::Appearance { radius: cosmic.corner_radii.radius_s.into(), }, shadow: Shadow::default(), + snap: true, } } diff --git a/src/widget/nav_bar_toggle.rs b/src/widget/nav_bar_toggle.rs index 68dd528b..b0849dd2 100644 --- a/src/widget/nav_bar_toggle.rs +++ b/src/widget/nav_bar_toggle.rs @@ -3,7 +3,7 @@ //! A button for toggling the navigation side panel. -use crate::{widget, Element}; +use crate::{Element, widget}; use derive_setters::Setters; #[derive(Setters)] @@ -11,39 +11,33 @@ pub struct NavBarToggle { active: bool, #[setters(strip_option)] on_toggle: Option, - style: crate::theme::Button, + class: crate::theme::Button, selected: bool, } #[must_use] -pub fn nav_bar_toggle() -> NavBarToggle { +pub const fn nav_bar_toggle() -> NavBarToggle { NavBarToggle { active: false, on_toggle: None, - style: crate::theme::Button::Text, + class: crate::theme::Button::NavToggle, selected: false, } } -impl<'a, Message: 'static + Clone> From> for Element<'a, Message> { +impl From> for Element<'_, Message> { fn from(nav_bar_toggle: NavBarToggle) -> Self { let icon = if nav_bar_toggle.active { - widget::icon::from_svg_bytes( - &include_bytes!("../../res/icons/navbar-open-symbolic.svg")[..], - ) - .symbolic(true) + "navbar-open-symbolic" } else { - widget::icon::from_svg_bytes( - &include_bytes!("../../res/icons/navbar-closed-symbolic.svg")[..], - ) - .symbolic(true) + "navbar-closed-symbolic" }; - widget::button::icon(icon) + widget::button::icon(widget::icon::from_name(icon)) .padding([8, 16]) .on_press_maybe(nav_bar_toggle.on_toggle) .selected(nav_bar_toggle.selected) - .style(nav_bar_toggle.style) + .class(nav_bar_toggle.class) .into() } } diff --git a/src/widget/popover.rs b/src/widget/popover.rs index cb15da91..af5370a8 100644 --- a/src/widget/popover.rs +++ b/src/widget/popover.rs @@ -1,20 +1,21 @@ // Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 -//! A widget showing a popup in an overlay positioned relative to another widget. +//! A container which displays an overlay when a popup widget is attached. +use iced::widget; 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::touch; -use iced_core::widget::{tree, Operation, OperationOutputWrapper, Tree}; +use iced_core::widget::{Operation, Tree}; use iced_core::{ Clipboard, Element, Layout, Length, Point, Rectangle, Shell, Size, Vector, Widget, }; -pub use iced_style::container::{Appearance, StyleSheet}; +pub use iced_widget::container::{Catalog, Style}; pub fn popover<'a, Message, Renderer>( content: impl Into>, @@ -30,8 +31,10 @@ pub enum Position { Point(Point), } +/// A container which displays overlays when a popup widget is assigned. #[must_use] pub struct Popover<'a, Message, Renderer> { + id: widget::Id, content: Element<'a, Message, crate::Theme, Renderer>, modal: bool, popup: Option>, @@ -42,6 +45,7 @@ pub struct Popover<'a, Message, Renderer> { impl<'a, Message, Renderer> Popover<'a, Message, Renderer> { pub fn new(content: impl Into>) -> Self { Self { + id: widget::Id::unique(), content: content.into(), modal: false, popup: None, @@ -50,42 +54,51 @@ impl<'a, Message, Renderer> Popover<'a, Message, Renderer> { } } - /// A modal popup interrupts user inputs and demands action. + /// Set the Id + #[inline] + pub fn id(mut self, id: widget::Id) -> Self { + self.id = id; + self + } + + /// A modal popup intercepts user inputs while a popup is active. + #[inline] pub fn modal(mut self, modal: bool) -> Self { self.modal = modal; self } /// Emitted when the popup is closed. + #[inline] pub fn on_close(mut self, on_close: Message) -> Self { self.on_close = Some(on_close); self } + #[inline] pub fn popup(mut self, popup: impl Into>) -> Self { self.popup = Some(popup.into()); self } + #[inline] pub fn position(mut self, position: Position) -> Self { self.position = position; self } - - // TODO More options for positioning similar to GdkPopup, xdg_popup } -impl<'a, Message: Clone, Renderer> Widget - for Popover<'a, Message, Renderer> +impl Widget + for Popover<'_, Message, Renderer> where Renderer: iced_core::Renderer, { - fn tag(&self) -> tree::Tag { - tree::Tag::of::() + fn id(&self) -> Option { + Some(self.id.clone()) } - fn state(&self) -> tree::State { - tree::State::new(State { is_open: true }) + fn set_id(&mut self, id: widget::Id) { + self.id = id; } fn children(&self) -> Vec { @@ -109,61 +122,71 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { let tree = &mut tree.children[0]; - self.content.as_widget().layout(tree, renderer, limits) + self.content.as_widget_mut().layout(tree, renderer, limits) } fn operate( - &self, + &mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation>, + operation: &mut dyn Operation, ) { + // Skip operating on background content, prevents Tab from escaping + if self.modal && self.popup.is_some() { + return; + } self.content - .as_widget() - .operate(&mut tree.children[0], layout, renderer, operation); + .as_widget_mut() + .operate(content_tree_mut(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 { - if !self.modal - && matches!( - event, - Event::Mouse(mouse::Event::ButtonPressed(_)) - | Event::Touch(touch::Event::FingerPressed { .. }) - ) - { - let state = tree.state.downcast_mut::(); - let was_open = state.is_open; - state.is_open = cursor_position.is_over(layout.bounds()); - - if let Some(on_close) = self.on_close.clone() { - if was_open && !state.is_open { - shell.publish(on_close); + ) { + if self.popup.is_some() { + if self.modal { + if matches!(event, Event::Mouse(_) | Event::Touch(_)) { + shell.capture_event(); + return; + } + } else if let Some(on_close) = self.on_close.as_ref() { + if matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(touch::Event::FingerPressed { .. }) + ) && !cursor_position.is_over(layout.bounds()) + { + shell.publish(on_close.clone()); } } } - self.content.as_widget_mut().on_event( + // Hide cursor from background content when modal popup is active + let cursor = if self.modal && self.popup.is_some() { + mouse::Cursor::Unavailable + } else { + cursor_position + }; + self.content.as_widget_mut().update( &mut tree.children[0], event, layout, - cursor_position, + cursor, renderer, clipboard, shell, @@ -179,8 +202,11 @@ where viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { + if self.modal && self.popup.is_some() && cursor_position.is_over(layout.bounds()) { + return mouse::Interaction::None; + } self.content.as_widget().mouse_interaction( - &tree.children[0], + content_tree(tree), layout, cursor_position, viewport, @@ -198,13 +224,19 @@ where cursor_position: mouse::Cursor, viewport: &Rectangle, ) { + // Hide cursor from background content when a modal popup is active + let cursor = if self.modal && self.popup.is_some() { + mouse::Cursor::Unavailable + } else { + cursor_position + }; self.content.as_widget().draw( - &tree.children[0], + content_tree(tree), renderer, theme, renderer_style, layout, - cursor_position, + cursor, viewport, ); } @@ -212,13 +244,11 @@ where fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + mut translation: Vector, ) -> Option> { - if !tree.state.downcast_mut::().is_open { - return None; - } - if let Some(popup) = &mut self.popup { let bounds = layout.bounds(); @@ -239,19 +269,23 @@ where // Round position to prevent rendering issues overlay_position.x = overlay_position.x.round(); overlay_position.y = overlay_position.y.round(); - - Some(overlay::Element::new( - overlay_position, - Box::new(Overlay { - tree: &mut tree.children[1], - content: popup, - position: self.position, - }), - )) + translation.x += overlay_position.x; + translation.y += overlay_position.y; + Some(overlay::Element::new(Box::new(Overlay { + tree: &mut tree.children[1], + content: popup, + position: self.position, + pos: Point::new(translation.x, translation.y), + modal: self.modal, + }))) } else { - self.content - .as_widget_mut() - .overlay(&mut tree.children[0], layout, renderer) + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) } } @@ -263,12 +297,25 @@ where dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { self.content.as_widget().drag_destinations( - &tree.children[0], + content_tree(tree), layout, renderer, dnd_rectangles, ); } + + #[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.content + .as_widget() + .a11y_nodes(layout, content_tree(state), p) + } } impl<'a, Message, Renderer> From> @@ -286,25 +333,22 @@ pub struct Overlay<'a, 'b, Message, Renderer> { tree: &'a mut Tree, content: &'a mut Element<'b, Message, crate::Theme, Renderer>, position: Position, + pos: Point, + modal: bool, } -impl<'a, 'b, Message, Renderer> overlay::Overlay - for Overlay<'a, 'b, Message, Renderer> +impl overlay::Overlay + for Overlay<'_, '_, Message, Renderer> where Message: Clone, Renderer: iced_core::Renderer, { - fn layout( - &mut self, - renderer: &Renderer, - bounds: Size, - mut position: Point, - _translation: iced::Vector, - ) -> layout::Node { + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + let mut position = self.pos; let limits = layout::Limits::new(Size::UNIT, bounds); let node = self .content - .as_widget() + .as_widget_mut() .layout(self.tree, renderer, &limits); match self.position { Position::Center => { @@ -342,23 +386,31 @@ where &mut self, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation>, + operation: &mut dyn Operation<()>, ) { self.content - .as_widget() + .as_widget_mut() .operate(self.tree, layout, renderer, operation); } - fn on_event( + fn update( &mut self, - event: Event, + event: &Event, layout: Layout<'_>, cursor_position: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - ) -> event::Status { - self.content.as_widget_mut().on_event( + ) { + if self.modal + && matches!(event, Event::Mouse(_) | Event::Touch(_)) + && !cursor_position.is_over(layout.bounds()) + { + shell.capture_event(); + return; + } + + self.content.as_widget_mut().update( self.tree, event, layout, @@ -374,14 +426,17 @@ where &self, layout: Layout<'_>, cursor_position: mouse::Cursor, - viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { + if self.modal && !cursor_position.is_over(layout.bounds()) { + return mouse::Interaction::None; + } + self.content.as_widget().mouse_interaction( self.tree, layout, cursor_position, - viewport, + &layout.bounds(), renderer, ) } @@ -408,12 +463,16 @@ where fn overlay<'c>( &'c mut self, - layout: Layout<'_>, + layout: Layout<'c>, renderer: &Renderer, ) -> Option> { - self.content - .as_widget_mut() - .overlay(&mut self.tree, layout, renderer) + self.content.as_widget_mut().overlay( + self.tree, + layout, + renderer, + &layout.bounds(), + Default::default(), + ) } } @@ -422,3 +481,13 @@ where struct State { is_open: bool, } + +/// The first child in [`Popover::children`] is always the wrapped content. +fn content_tree(tree: &Tree) -> &Tree { + &tree.children[0] +} + +/// The first child in [`Popover::children`] is always the wrapped content. +fn content_tree_mut(tree: &mut Tree) -> &mut Tree { + &mut tree.children[0] +} diff --git a/src/widget/progress_bar/circular.rs b/src/widget/progress_bar/circular.rs new file mode 100644 index 00000000..fa8c38fe --- /dev/null +++ b/src/widget/progress_bar/circular.rs @@ -0,0 +1,462 @@ +//! Show a circular progress indicator. +use super::style::StyleSheet; +use crate::anim::smootherstep; +use iced::advanced::layout; +use iced::advanced::renderer; +use iced::advanced::widget::tree::{self, Tree}; +use iced::advanced::{self, Clipboard, Layout, Shell, Widget}; +use iced::mouse; +use iced::time::Instant; +use iced::widget::canvas; +use iced::window; +use iced::{Element, Event, Length, Radians, Rectangle, Renderer, Size, Vector}; + +use std::f32::consts::PI; +use std::time::Duration; + +const MIN_ANGLE: Radians = Radians(PI / 8.0); + +#[must_use] +pub struct Circular +where + Theme: StyleSheet, +{ + size: f32, + bar_height: f32, + style: ::Style, + cycle_duration: Duration, + rotation_duration: Duration, + progress: Option, +} + +impl Circular +where + Theme: StyleSheet, +{ + /// Creates a new [`Circular`] with the given content. + pub fn new() -> Self { + Circular { + size: 40.0, + bar_height: 4.0, + style: ::Style::default(), + cycle_duration: Duration::from_millis(1500), + rotation_duration: Duration::from_secs(2), + progress: None, + } + } + + /// Sets the size of the [`Circular`]. + pub fn size(mut self, size: f32) -> Self { + self.size = size; + self + } + + /// Sets the bar height of the [`Circular`]. + pub fn bar_height(mut self, bar_height: f32) -> Self { + self.bar_height = bar_height; + self + } + + /// Sets the style variant of this [`Circular`]. + pub fn style(mut self, style: ::Style) -> Self { + self.style = style; + self + } + + /// Sets the cycle duration of this [`Circular`]. + pub fn cycle_duration(mut self, duration: Duration) -> Self { + self.cycle_duration = duration / 2; + self + } + + /// Sets the base rotation duration of this [`Circular`]. This is the duration that a full + /// rotation would take if the cycle rotation were set to 0.0 (no expanding or contracting) + pub fn rotation_duration(mut self, duration: Duration) -> Self { + self.rotation_duration = duration; + self + } + + /// Override the default behavior by providing a determinate progress value between `0.0` and `1.0`. + pub fn progress(mut self, progress: f32) -> Self { + self.progress = Some(progress.clamp(0.0, 1.0)); + self + } + + fn min_wrap_angle(&self, track_radius: f32) -> (f32, f32) { + let cap_angle = self.bar_height / track_radius; + let gap = MIN_ANGLE.0.max(cap_angle); + (gap - cap_angle, 2.0 * PI - gap * 2.0) + } +} + +impl Default for Circular +where + Theme: StyleSheet, +{ + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Copy)] +enum Animation { + Expanding { + start: Instant, + progress: f32, + rotation: u32, + last: Instant, + }, + Contracting { + start: Instant, + progress: f32, + rotation: u32, + last: Instant, + }, +} + +impl Default for Animation { + fn default() -> Self { + Self::Expanding { + start: Instant::now(), + progress: 0.0, + rotation: 0, + last: Instant::now(), + } + } +} + +impl Animation { + fn next(&self, additional_rotation: u32, wrap_angle: f32, now: Instant) -> Self { + match self { + Self::Expanding { rotation, .. } => Self::Contracting { + start: now, + progress: 0.0, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + Self::Contracting { rotation, .. } => Self::Expanding { + start: now, + progress: 0.0, + rotation: rotation.wrapping_add( + (f64::from((wrap_angle) / (2.0 * PI)) * f64::from(u32::MAX)) as u32, + ), + last: now, + }, + } + } + + fn start(&self) -> Instant { + match self { + Self::Expanding { start, .. } | Self::Contracting { start, .. } => *start, + } + } + + fn last(&self) -> Instant { + match self { + Self::Expanding { last, .. } | Self::Contracting { last, .. } => *last, + } + } + + fn timed_transition( + &self, + cycle_duration: Duration, + rotation_duration: Duration, + wrap_angle: f32, + now: Instant, + ) -> Self { + let elapsed = now.duration_since(self.start()); + let additional_rotation = ((now - self.last()).as_secs_f32() + / rotation_duration.as_secs_f32() + * (u32::MAX) as f32) as u32; + + match elapsed { + elapsed if elapsed > cycle_duration => self.next(additional_rotation, wrap_angle, now), + _ => self.with_elapsed(cycle_duration, additional_rotation, elapsed, now), + } + } + + fn with_elapsed( + &self, + cycle_duration: Duration, + additional_rotation: u32, + elapsed: Duration, + now: Instant, + ) -> Self { + let progress = elapsed.as_secs_f32() / cycle_duration.as_secs_f32(); + match self { + Self::Expanding { + start, rotation, .. + } => Self::Expanding { + start: *start, + progress, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + Self::Contracting { + start, rotation, .. + } => Self::Contracting { + start: *start, + progress, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + } + } + + fn rotation(&self) -> f32 { + match self { + Self::Expanding { rotation, .. } | Self::Contracting { rotation, .. } => { + *rotation as f32 / u32::MAX as f32 + } + } + } +} + +#[derive(Default)] +struct State { + animation: Animation, + cache: canvas::Cache, + progress: Option, +} + +impl Widget for Circular +where + Message: Clone, + Theme: StyleSheet, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn size(&self) -> Size { + Size { + width: Length::Fixed(self.size), + height: Length::Fixed(self.size), + } + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic(limits, self.size, self.size) + } + + 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 state = tree.state.downcast_mut::(); + if self.progress.is_some() { + if !float_cmp::approx_eq!( + f32, + state.progress.unwrap_or_default(), + self.progress.unwrap_or_default() + ) { + state.progress = self.progress; + state.cache.clear(); + } + return; + } + if let Event::Window(window::Event::RedrawRequested(now)) = event { + let (_, wrap_angle) = self.min_wrap_angle(self.size / 2.0 - self.bar_height); + state.animation = state.animation.timed_transition( + self.cycle_duration, + self.rotation_duration, + wrap_angle, + *now, + ); + + state.cache.clear(); + shell.request_redraw(); + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + use advanced::Renderer as _; + + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + let custom_style = + ::appearance(theme, &self.style, self.progress.is_some(), true); + + let geometry = state.cache.draw(renderer, bounds.size(), |frame| { + let track_radius = frame.width() / 2.0 - self.bar_height; + let track_path = canvas::Path::circle(frame.center(), track_radius); + + frame.stroke( + &track_path, + canvas::Stroke::default() + .with_color(custom_style.track_color) + .with_width(self.bar_height), + ); + + if let Some(progress) = self.progress { + // outer border + if let Some(border_color) = custom_style.border_color { + let border_path = + canvas::Path::circle(frame.center(), track_radius + self.bar_height / 2.0); + + frame.stroke( + &border_path, + canvas::Stroke::default() + .with_color(border_color) + .with_width(1.0), + ); + } + + // inner border + if let Some(border_color) = custom_style.border_color { + let border_path = + canvas::Path::circle(frame.center(), track_radius - self.bar_height / 2.0); + + frame.stroke( + &border_path, + canvas::Stroke::default() + .with_color(border_color) + .with_width(1.0), + ); + } + + // bar + let mut builder = canvas::path::Builder::new(); + + builder.arc(canvas::path::Arc { + center: frame.center(), + radius: track_radius, + start_angle: Radians(-PI / 2.0), + end_angle: Radians(-PI / 2.0 + progress * 2.0 * PI), + }); + + let bar_path = builder.build(); + + frame.stroke( + &bar_path, + canvas::Stroke::default() + .with_color(custom_style.bar_color) + .with_width(self.bar_height), + ); + + let mut builder = canvas::path::Builder::new(); + + // get center of end of arc for rounded cap + let end_angle = -PI / 2.0 + progress * 2.0 * PI; + let end_center = + frame.center() + Vector::new(end_angle.cos(), end_angle.sin()) * track_radius; + builder.arc(canvas::path::Arc { + center: end_center, + radius: self.bar_height / 2.0, + start_angle: Radians(end_angle), + end_angle: Radians(end_angle + PI), + }); + + // get center of start of arc for rounded cap + let start_angle = -PI / 2.0; + let start_center = frame.center() + + Vector::new(start_angle.cos(), start_angle.sin()) * track_radius; + builder.arc(canvas::path::Arc { + center: start_center, + radius: self.bar_height / 2.0, + start_angle: Radians(start_angle - PI), + end_angle: Radians(start_angle), + }); + + let cap_path = builder.build(); + frame.fill(&cap_path, custom_style.bar_color); + } else { + let mut builder = canvas::path::Builder::new(); + + let start = state.animation.rotation() * 2.0 * PI; + let (min_angle, wrap_angle) = self.min_wrap_angle(track_radius); + let (start_angle, end_angle) = match state.animation { + Animation::Expanding { progress, .. } => ( + start, + start + min_angle + wrap_angle * smootherstep(progress), + ), + Animation::Contracting { progress, .. } => ( + start + wrap_angle * smootherstep(progress), + start + min_angle + wrap_angle, + ), + }; + builder.arc(canvas::path::Arc { + center: frame.center(), + radius: track_radius, + start_angle: Radians(start_angle), + end_angle: Radians(end_angle), + }); + + let bar_path = builder.build(); + + frame.stroke( + &bar_path, + canvas::Stroke::default() + .with_color(custom_style.bar_color) + .with_width(self.bar_height), + ); + + let mut builder = canvas::path::Builder::new(); + + // get center of end of arc for rounded cap + let end_center = + frame.center() + Vector::new(end_angle.cos(), end_angle.sin()) * track_radius; + builder.arc(canvas::path::Arc { + center: end_center, + radius: self.bar_height / 2.0, + start_angle: Radians(end_angle), + end_angle: Radians(end_angle + PI), + }); + + // get center of start of arc for rounded cap + let start_center = frame.center() + + Vector::new(start_angle.cos(), start_angle.sin()) * track_radius; + builder.arc(canvas::path::Arc { + center: start_center, + radius: self.bar_height / 2.0, + start_angle: Radians(start_angle - PI), + end_angle: Radians(start_angle), + }); + + let cap_path = builder.build(); + frame.fill(&cap_path, custom_style.bar_color); + } + }); + + renderer.with_translation(Vector::new(bounds.x, bounds.y), |renderer| { + use iced::advanced::graphics::geometry::Renderer as _; + + renderer.draw_geometry(geometry); + }); + } +} + +impl<'a, Message, Theme> From> for Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Theme: StyleSheet + 'a, +{ + fn from(circular: Circular) -> Self { + Self::new(circular) + } +} diff --git a/src/widget/progress_bar/linear.rs b/src/widget/progress_bar/linear.rs new file mode 100644 index 00000000..226b2b5f --- /dev/null +++ b/src/widget/progress_bar/linear.rs @@ -0,0 +1,306 @@ +//! Show a linear progress indicator. +use iced::advanced::layout; +use iced::advanced::renderer::{self, Quad}; +use iced::advanced::widget::tree::{self, Tree}; +use iced::advanced::{self, Clipboard, Layout, Shell, Widget}; +use iced::mouse; +use iced::time::Instant; +use iced::window; +use iced::{Background, Element, Event, Length, Rectangle, Size}; + +use crate::anim::smootherstep; + +use super::style::StyleSheet; + +use std::time::Duration; + +#[must_use] +pub struct Linear +where + Theme: StyleSheet, +{ + width: Length, + girth: Length, + style: Theme::Style, + cycle_duration: Duration, + progress: Option, +} + +impl Linear +where + Theme: StyleSheet, +{ + /// Creates a new [`Linear`] with the given content. + pub fn new() -> Self { + Linear { + width: Length::Fixed(100.0), + girth: Length::Fixed(4.0), + style: Theme::Style::default(), + cycle_duration: Duration::from_millis(1500), + progress: None, + } + } + + /// Sets the width of the [`Linear`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the girth of the [`Linear`]. + pub fn girth(mut self, girth: impl Into) -> Self { + self.girth = girth.into(); + self + } + + /// Sets the style variant of this [`Linear`]. + pub fn style(mut self, style: impl Into) -> Self { + self.style = style.into(); + self + } + + /// Sets the cycle duration of this [`Linear`]. + pub fn cycle_duration(mut self, duration: Duration) -> Self { + self.cycle_duration = duration / 2; + self + } + + /// Override the default behavior by providing a determinate progress value between `0.0` and `1.0`. + pub fn progress(mut self, progress: f32) -> Self { + self.progress = Some(progress.clamp(0.0, 1.0)); + self + } +} + +impl Default for Linear +where + Theme: StyleSheet, +{ + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Copy)] +enum State { + Expanding { start: Instant, progress: f32 }, + Contracting { start: Instant, progress: f32 }, +} + +impl Default for State { + fn default() -> Self { + Self::Expanding { + start: Instant::now(), + progress: 0.0, + } + } +} + +impl State { + fn next(&self, now: Instant) -> Self { + match self { + Self::Expanding { .. } => Self::Contracting { + start: now, + progress: 0.0, + }, + Self::Contracting { .. } => Self::Expanding { + start: now, + progress: 0.0, + }, + } + } + + fn start(&self) -> Instant { + match self { + Self::Expanding { start, .. } | Self::Contracting { start, .. } => *start, + } + } + + fn timed_transition(&self, cycle_duration: Duration, now: Instant) -> Self { + let elapsed = now.duration_since(self.start()); + + match elapsed { + elapsed if elapsed > cycle_duration => self.next(now), + _ => self.with_elapsed(cycle_duration, elapsed), + } + } + + fn with_elapsed(&self, cycle_duration: Duration, elapsed: Duration) -> Self { + let progress = elapsed.as_secs_f32() / cycle_duration.as_secs_f32(); + match self { + Self::Expanding { start, .. } => Self::Expanding { + start: *start, + progress, + }, + Self::Contracting { start, .. } => Self::Contracting { + start: *start, + progress, + }, + } + } +} + +impl Widget for Linear +where + Message: Clone, + Theme: StyleSheet, + Renderer: advanced::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.girth, + } + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic(limits, self.width, self.girth) + } + + 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, + ) { + if self.progress.is_some() { + return; + } + + let state = tree.state.downcast_mut::(); + + if let Event::Window(window::Event::RedrawRequested(now)) = event { + *state = state.timed_transition(self.cycle_duration, *now); + + shell.request_redraw(); + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let custom_style = theme.appearance(&self.style, self.progress.is_some(), false); + let state = tree.state.downcast_ref::(); + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }, + border: iced::Border { + width: if custom_style.border_color.is_some() { + 1.0 + } else { + 0.0 + }, + color: custom_style.border_color.unwrap_or(custom_style.bar_color), + radius: custom_style.border_radius.into(), + }, + snap: true, + ..renderer::Quad::default() + }, + Background::Color(custom_style.track_color), + ); + + if let Some(progress) = self.progress { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x, + y: bounds.y, + width: progress * bounds.width, + height: bounds.height, + }, + border: iced::Border { + width: 0., + color: iced::Color::TRANSPARENT, + radius: custom_style.border_radius.into(), + }, + snap: true, + ..renderer::Quad::default() + }, + Background::Color(custom_style.bar_color), + ); + } else { + match state { + State::Expanding { progress, .. } => renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x, + y: bounds.y, + width: smootherstep(*progress) * bounds.width, + height: bounds.height, + }, + border: iced::Border { + width: 0., + color: iced::Color::TRANSPARENT, + radius: custom_style.border_radius.into(), + }, + snap: true, + ..renderer::Quad::default() + }, + Background::Color(custom_style.bar_color), + ), + + State::Contracting { progress, .. } => renderer.fill_quad( + Quad { + bounds: Rectangle { + x: bounds.x + smootherstep(*progress) * bounds.width, + y: bounds.y, + width: (1.0 - smootherstep(*progress)) * bounds.width, + height: bounds.height, + }, + border: iced::Border { + width: 0., + color: iced::Color::TRANSPARENT, + radius: custom_style.border_radius.into(), + }, + snap: true, + ..renderer::Quad::default() + }, + Background::Color(custom_style.bar_color), + ), + } + } + } +} + +impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Theme: StyleSheet + 'a, + Renderer: iced::advanced::Renderer + 'a, +{ + fn from(linear: Linear) -> Self { + Self::new(linear) + } +} diff --git a/src/widget/progress_bar/mod.rs b/src/widget/progress_bar/mod.rs new file mode 100644 index 00000000..4e277b0a --- /dev/null +++ b/src/widget/progress_bar/mod.rs @@ -0,0 +1,23 @@ +pub mod circular; +pub mod linear; +pub mod style; + +/// A spinner / throbber widget that can be used to indicate that some operation is in progress. +pub fn indeterminate_circular() -> circular::Circular { + circular::Circular::new() +} + +/// A linear throbber widget that can be used to indicate that some operation is in progress. +pub fn indeterminate_linear() -> linear::Linear { + linear::Linear::new() +} + +/// A circular progress spinner widget that can be used to indicate the progress of some operation. +pub fn determinate_circular(progress: f32) -> circular::Circular { + circular::Circular::new().progress(progress) +} + +/// A linear progress bar widget that can be used to indicate the progress of some operation. +pub fn determinate_linear(progress: f32) -> linear::Linear { + linear::Linear::new().progress(progress) +} diff --git a/src/widget/progress_bar/style.rs b/src/widget/progress_bar/style.rs new file mode 100644 index 00000000..db2fe64d --- /dev/null +++ b/src/widget/progress_bar/style.rs @@ -0,0 +1,105 @@ +use iced::Color; + +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// The track [`Color`] of the progress indicator. + pub track_color: Color, + /// The bar [`Color`] of the progress indicator. + pub bar_color: Color, + /// The border [`Color`] of the progress indicator. + pub border_color: Option, + /// The border radius of the progress indicator. + pub border_radius: f32, +} + +impl std::default::Default for Appearance { + fn default() -> Self { + Self { + track_color: Color::TRANSPARENT, + bar_color: Color::BLACK, + border_color: None, + border_radius: 0.0, + } + } +} + +/// A set of rules that dictate the style of an indicator. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default; + + /// Produces the active [`Appearance`] of a indicator. + fn appearance( + &self, + style: &Self::Style, + is_determinate: bool, + is_circular: bool, + ) -> Appearance; +} + +impl StyleSheet for iced::Theme { + type Style = (); + + fn appearance( + &self, + _style: &Self::Style, + _is_determinate: bool, + _is_circular: bool, + ) -> Appearance { + let palette = self.extended_palette(); + + Appearance { + track_color: palette.background.weak.color, + bar_color: palette.primary.base.color, + border_color: None, + border_radius: 0.0, + } + } +} + +impl StyleSheet for crate::Theme { + type Style = (); + + fn appearance( + &self, + _style: &Self::Style, + is_determinate: bool, + is_circular: bool, + ) -> Appearance { + let cur = self.current_container(); + let mut cur_divider = cur.divider; + cur_divider.alpha = 0.5; + let theme = self.cosmic(); + + let (mut track_color, bar_color) = if theme.is_dark && theme.is_high_contrast { + ( + theme.palette.neutral_6.into(), + theme.accent_text_color().into(), + ) + } else if theme.is_dark { + (theme.palette.neutral_5.into(), theme.accent_color().into()) + } else if theme.is_high_contrast { + ( + theme.palette.neutral_4.into(), + theme.accent_text_color().into(), + ) + } else { + (theme.palette.neutral_3.into(), theme.accent_color().into()) + }; + + if !is_determinate && is_circular { + track_color = Color::TRANSPARENT; + } + + Appearance { + track_color, + bar_color, + border_color: if is_determinate && theme.is_high_contrast { + Some(cur_divider.into()) + } else { + None + }, + border_radius: theme.corner_radii.radius_xl[0], + } + } +} diff --git a/src/widget/radio.rs b/src/widget/radio.rs index c509b491..c3f115c0 100644 --- a/src/widget/radio.rs +++ b/src/widget/radio.rs @@ -1,4 +1,6 @@ //! Create choices using radio buttons. +use crate::{Theme, theme}; +use iced::border; use iced_core::event::{self, Event}; use iced_core::layout; use iced_core::mouse; @@ -7,17 +9,18 @@ use iced_core::renderer; use iced_core::touch; use iced_core::widget::tree::Tree; use iced_core::{ - Border, Clipboard, Element, Layout, Length, Pixels, Rectangle, Shell, Size, Widget, + Border, Clipboard, Element, Layout, Length, Pixels, Rectangle, Shell, Size, Vector, Widget, }; -pub use iced_style::radio::{Appearance, StyleSheet}; +use iced_widget::radio as iced_radio; +pub use iced_widget::radio::Catalog; -pub fn radio<'a, Message: Clone, Theme: StyleSheet, V, F>( +pub fn radio<'a, Message: Clone, V, F>( label: impl Into>, value: V, selected: Option, f: F, -) -> Radio<'a, Message, Theme, crate::Renderer> +) -> Radio<'a, Message, crate::Renderer> where V: Eq + Copy, F: FnOnce(V) -> Message, @@ -30,7 +33,7 @@ where /// # Example /// ```no_run /// # type Radio<'a, Message> = -/// # cosmic::widget::Radio<'a, Message, cosmic::Theme, cosmic::Renderer>; +/// # cosmic::widget::Radio<'a, Message, cosmic::Renderer>; /// # /// # use cosmic::widget::text; /// # use cosmic::iced::widget::column; @@ -83,32 +86,26 @@ where /// let content = column![a, b, c, all]; /// ``` #[allow(missing_debug_implementations)] -pub struct Radio<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> +pub struct Radio<'a, Message, Renderer = crate::Renderer> where - Theme: StyleSheet, Renderer: iced_core::Renderer, { is_selected: bool, on_click: Message, - label: Element<'a, Message, Theme, Renderer>, + label: Option>, width: Length, size: f32, spacing: f32, - style: Theme::Style, } -impl<'a, Message, Theme, Renderer> Radio<'a, Message, Theme, Renderer> +impl<'a, Message, Renderer> Radio<'a, Message, Renderer> where Message: Clone, - Theme: StyleSheet, Renderer: iced_core::Renderer, { /// The default size of a [`Radio`] button. pub const DEFAULT_SIZE: f32 = 16.0; - /// The default spacing of a [`Radio`] button. - pub const DEFAULT_SPACING: f32 = 8.0; - /// Creates a new [`Radio`] button. /// /// It expects: @@ -126,11 +123,29 @@ where Radio { is_selected: Some(value) == selected, on_click: f(value), - label: label.into(), + label: Some(label.into()), width: Length::Shrink, size: Self::DEFAULT_SIZE, - spacing: Self::DEFAULT_SPACING, - style: Default::default(), + spacing: theme::spacing().space_xs as f32, + } + } + + /// Creates a new [`Radio`] button without a label. + /// + /// This is intended for internal use with the settings item builder, + /// where the label comes from the settings item title instead. + pub(crate) fn new_no_label(value: V, selected: Option, f: F) -> Self + where + V: Eq + Copy, + F: FnOnce(V) -> Message, + { + Radio { + is_selected: Some(value) == selected, + on_click: f(value), + label: None, + width: Length::Shrink, + size: Self::DEFAULT_SIZE, + spacing: theme::spacing().space_xs as f32, } } @@ -154,28 +169,25 @@ where self.spacing = spacing.into().0; self } - - #[must_use] - /// Sets the style of the [`Radio`] button. - pub fn style(mut self, style: impl Into) -> Self { - self.style = style.into(); - self - } } -impl<'a, Message, Theme, Renderer> Widget - for Radio<'a, Message, Theme, Renderer> +impl Widget for Radio<'_, Message, Renderer> where Message: Clone, - Theme: StyleSheet, Renderer: iced_core::Renderer, { fn children(&self) -> Vec { - vec![Tree::new(&self.label)] + if let Some(label) = &self.label { + vec![Tree::new(label)] + } else { + vec![] + } } fn diff(&mut self, tree: &mut Tree) { - tree.children[0].diff(&mut self.label); + if let Some(label) = &mut self.label { + tree.diff_children(std::slice::from_mut(label)); + } } fn size(&self) -> Size { Size { @@ -185,78 +197,80 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { - layout::next_to_each_other( - &limits.width(self.width), - self.spacing, - |_| layout::Node::new(Size::new(self.size, self.size)), - |limits| { - self.label - .as_widget() - .layout(&mut tree.children[0], renderer, limits) - }, - ) + if let Some(label) = &mut self.label { + layout::next_to_each_other( + &limits.width(self.width), + self.spacing, + |_| layout::Node::new(Size::new(self.size, self.size)), + |limits| { + label + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + }, + ) + } else { + layout::Node::new(Size::new(self.size, self.size)) + } } 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.label.as_widget().operate( - &mut tree.children[0], - layout.children().nth(1).unwrap(), - renderer, - operation, - ); + if let Some(label) = &mut self.label { + label.as_widget_mut().operate( + &mut tree.children[0], + layout.children().nth(1).unwrap(), + renderer, + operation, + ); + } } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { - let status = self.label.as_widget_mut().on_event( - &mut tree.children[0], - event.clone(), - layout.children().nth(1).unwrap(), - cursor, - renderer, - clipboard, - shell, - viewport, - ); + ) { + if let Some(label) = &mut self.label { + label.as_widget_mut().update( + &mut tree.children[0], + event, + layout.children().nth(1).unwrap(), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } - if status == event::Status::Ignored { + if !shell.is_event_captured() { match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) => { if cursor.is_over(layout.bounds()) { shell.publish(self.on_click.clone()); - - return event::Status::Captured; + shell.capture_event(); + return; } } _ => {} } - - event::Status::Ignored - } else { - status } } @@ -268,13 +282,17 @@ where viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { - let interaction = self.label.as_widget().mouse_interaction( - &tree.children[0], - layout.children().nth(1).unwrap(), - cursor, - viewport, - renderer, - ); + let interaction = if let Some(label) = &self.label { + label.as_widget().mouse_interaction( + &tree.children[0], + layout.children().nth(1).unwrap(), + cursor, + viewport, + renderer, + ) + } else { + mouse::Interaction::default() + }; if interaction == mouse::Interaction::default() { if cursor.is_over(layout.bounds()) { @@ -299,24 +317,37 @@ where ) { let is_mouse_over = cursor.is_over(layout.bounds()); - let mut children = layout.children(); - let custom_style = if is_mouse_over { - theme.hovered(&self.style, self.is_selected) + theme.style( + &(), + iced_radio::Status::Hovered { + is_selected: self.is_selected, + }, + ) } else { - theme.active(&self.style, self.is_selected) + theme.style( + &(), + iced_radio::Status::Active { + is_selected: self.is_selected, + }, + ) + }; + + let (dot_bounds, label_layout) = if self.label.is_some() { + let mut children = layout.children(); + let dot_bounds = children.next().unwrap().bounds(); + (dot_bounds, children.next()) + } else { + (layout.bounds(), None) }; { - let layout = children.next().unwrap(); - let bounds = layout.bounds(); - - let size = bounds.width; + let size = dot_bounds.width; let dot_size = 6.0; renderer.fill_quad( renderer::Quad { - bounds, + bounds: dot_bounds, border: Border { radius: (size / 2.0).into(), width: custom_style.border_width, @@ -331,12 +362,12 @@ where renderer.fill_quad( renderer::Quad { bounds: Rectangle { - x: bounds.x + (size - dot_size) / 2.0, - y: bounds.y + (size - dot_size) / 2.0, + x: dot_bounds.x + (size - dot_size) / 2.0, + y: dot_bounds.y + (size - dot_size) / 2.0, width: dot_size, height: dot_size, }, - border: Border::with_radius(dot_size / 2.0), + border: border::rounded(dot_size / 2.0), ..renderer::Quad::default() }, custom_style.dot_color, @@ -344,9 +375,8 @@ where } } - { - let label_layout = children.next().unwrap(); - self.label.as_widget().draw( + if let (Some(label), Some(label_layout)) = (&self.label, label_layout) { + label.as_widget().draw( &tree.children[0], renderer, theme, @@ -361,13 +391,17 @@ where fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - self.label.as_widget_mut().overlay( + self.label.as_mut()?.as_widget_mut().overlay( &mut tree.children[0], layout.children().nth(1).unwrap(), renderer, + viewport, + translation, ) } @@ -376,25 +410,26 @@ where state: &Tree, layout: Layout<'_>, renderer: &Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { - self.label.as_widget().drag_destinations( - &state.children[0], - layout.children().nth(1).unwrap(), - renderer, - dnd_rectangles, - ); + if let Some(label) = &self.label { + label.as_widget().drag_destinations( + &state.children[0], + layout.children().nth(1).unwrap(), + renderer, + dnd_rectangles, + ); + } } } -impl<'a, Message, Theme, Renderer> From> +impl<'a, Message, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: 'a + Clone, - Theme: 'a + StyleSheet, Renderer: 'a + iced_core::Renderer, { - fn from(radio: Radio<'a, Message, Theme, Renderer>) -> Element<'a, Message, Theme, Renderer> { + fn from(radio: Radio<'a, Message, Renderer>) -> Element<'a, Message, Theme, Renderer> { Element::new(radio) } } diff --git a/src/widget/rectangle_tracker/mod.rs b/src/widget/rectangle_tracker/mod.rs index 93b861a3..b3066ecb 100644 --- a/src/widget/rectangle_tracker/mod.rs +++ b/src/widget/rectangle_tracker/mod.rs @@ -1,22 +1,22 @@ mod subscription; +use iced::Vector; use iced::futures::channel::mpsc::UnboundedSender; use iced::widget::Container; pub use subscription::*; -use iced_core::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::Tree; -use iced_core::{Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Widget}; +use iced_core::{Alignment, Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Widget}; use std::{fmt::Debug, hash::Hash}; -pub use iced_style::container::{Appearance, StyleSheet}; +pub use iced_widget::container::{Catalog, Style}; -pub fn rectangle_tracker<'a, Message, I, T>( +pub fn rectangle_tracking_container<'a, Message, I, T>( content: T, id: I, tx: UnboundedSender<(I, Rectangle)>, @@ -132,36 +132,43 @@ where /// Sets the content alignment for the horizontal axis of the [`Container`]. #[must_use] - pub fn align_x(mut self, alignment: alignment::Horizontal) -> Self { + 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 { + 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(); + 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(); + 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] + 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 style(mut self, style: impl Into<::Class<'a>>) -> Self { + self.container = self.container.class(style); self } @@ -197,7 +204,7 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, @@ -214,29 +221,27 @@ where } 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: &iced_core::Rectangle, - ) -> event::Status { - self.container.on_event( + ) { + self.container.update( tree, event, layout, @@ -271,7 +276,6 @@ where viewport: &Rectangle, ) { let _ = self.tx.unbounded_send((self.id, layout.bounds())); - self.container.draw( tree, renderer, @@ -286,10 +290,13 @@ where fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { - self.container.overlay(tree, layout, renderer) + self.container + .overlay(tree, layout, renderer, viewport, translation) } fn drag_destinations( @@ -297,11 +304,22 @@ where state: &Tree, layout: Layout<'_>, renderer: &Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { self.container .drag_destinations(state, layout, renderer, dnd_rectangles); } + + #[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, I> From> diff --git a/src/widget/rectangle_tracker/subscription.rs b/src/widget/rectangle_tracker/subscription.rs index 224f8d6c..02fa4329 100644 --- a/src/widget/rectangle_tracker/subscription.rs +++ b/src/widget/rectangle_tracker/subscription.rs @@ -1,21 +1,27 @@ use iced::{ + Rectangle, futures::{ - channel::mpsc::{unbounded, UnboundedReceiver}, StreamExt, + channel::mpsc::{UnboundedReceiver, unbounded}, + stream, }, - subscription, Rectangle, }; +use iced_futures::Subscription; use std::{collections::HashMap, fmt::Debug, hash::Hash}; use super::RectangleTracker; +#[cold] pub fn rectangle_tracker_subscription< I: 'static + Hash + Copy + Send + Sync + Debug, R: 'static + Hash + Copy + Send + Sync + Debug + Eq, >( id: I, -) -> iced::Subscription<(I, RectangleUpdate)> { - subscription::unfold(id, State::Ready, move |state| start_listening(id, state)) +) -> Subscription<(I, RectangleUpdate)> { + Subscription::run_with(id, |id| { + let id = *id; + stream::unfold(State::Ready, move |state| start_listening(id, state)) + }) } pub enum State { @@ -27,7 +33,7 @@ pub enum State { async fn start_listening( id: I, mut state: State, -) -> ((I, RectangleUpdate), State) { +) -> Option<((I, RectangleUpdate), State)> { loop { let (update, new_state) = match state { State::Ready => { @@ -65,11 +71,11 @@ async fn start_listening (None, State::Finished), }, - State::Finished => iced::futures::future::pending().await, + State::Finished => return None, }; state = new_state; if let Some(u) = update { - return (u, state); + return Some((u, state)); } } } diff --git a/src/widget/responsive_container.rs b/src/widget/responsive_container.rs new file mode 100644 index 00000000..b9b6a289 --- /dev/null +++ b/src/widget/responsive_container.rs @@ -0,0 +1,342 @@ +//! Responsive Container, which will notify of size changes. + +use iced::{Limits, Size}; +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, tree}; +use iced_core::{Clipboard, Element, Layout, Length, Rectangle, Shell, Vector, Widget}; + +pub(crate) fn responsive_container<'a, Message: 'static, Theme, E>( + content: E, + id: Id, + on_action: impl Fn(crate::surface::Action) -> Message + 'static, +) -> ResponsiveContainer<'a, Message, Theme, crate::Renderer> +where + E: Into>, + Theme: iced_widget::container::Catalog, + ::Class<'a>: From>, +{ + ResponsiveContainer::new(content, id, on_action) +} + +/// An element decorating some content. +/// +/// It is normally used for alignment purposes. +#[allow(missing_debug_implementations)] +pub struct ResponsiveContainer<'a, Message, Theme, Renderer> +where + Renderer: iced_core::Renderer, +{ + content: Element<'a, Message, Theme, Renderer>, + id: Id, + size: Option, + on_action: Box Message>, +} + +impl<'a, Message, Theme, Renderer> ResponsiveContainer<'a, Message, Theme, Renderer> +where + Renderer: iced_core::Renderer, +{ + /// Creates an empty [`IdContainer`]. + pub(crate) fn new( + content: T, + id: Id, + on_action: impl Fn(crate::surface::Action) -> Message + 'static, + ) -> Self + where + T: Into>, + { + ResponsiveContainer { + content: content.into(), + id, + size: None, + on_action: Box::new(on_action), + } + } + + pub(crate) fn size(mut self, size: Size) -> Self { + self.size = Some(size); + self + } +} + +impl Widget + for ResponsiveContainer<'_, Message, Theme, Renderer> +where + Renderer: iced_core::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::new()) + } + + 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 state = tree.state.downcast_mut::(); + let mut unrestricted_size = self.size.unwrap_or_else(|| { + let node = + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, &Limits::NONE); + node.size() + }); + + let cur_unrestricted_size = { + let node = + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, &Limits::NONE); + node.size() + }; + + let max_size = limits.max(); + + let old_max = state.limits.max(); + + state.needs_update = (cur_unrestricted_size.width > max_size.width) + || (cur_unrestricted_size.width > old_max.width) + || (cur_unrestricted_size.height > max_size.height) + || (cur_unrestricted_size.height > old_max.height) + || ((unrestricted_size.width <= max_size.width) + && (unrestricted_size.height <= max_size.height) + && (unrestricted_size.width - cur_unrestricted_size.width > 1. + || unrestricted_size.height - cur_unrestricted_size.height > 1.)); + + if unrestricted_size.width < cur_unrestricted_size.width { + state.needs_update = true; + unrestricted_size.width = cur_unrestricted_size.width; + } else if unrestricted_size.height < cur_unrestricted_size.height { + state.needs_update = true; + unrestricted_size.height = cur_unrestricted_size.height; + } + let node = self + .content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits); + let size = node.size(); + + if state.needs_update { + state.limits = *limits; + state.size = unrestricted_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, + ) { + let state = tree.state.downcast_mut::(); + + if state.needs_update { + shell.publish((self.on_action)( + crate::surface::Action::ResponsiveMenuBar { + menu_bar: self.id.clone(), + limits: state.limits, + size: state.size, + }, + )); + state.needs_update = false; + } + + 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: ResponsiveContainer<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(c) + } +} + +#[derive(Debug, Clone, Copy)] +struct State { + limits: Limits, + size: Size, + needs_update: bool, +} + +impl State { + fn new() -> Self { + Self { + limits: Limits::NONE, + size: Size::new(0., 0.), + needs_update: false, + } + } +} diff --git a/src/widget/responsive_menu_bar.rs b/src/widget/responsive_menu_bar.rs new file mode 100644 index 00000000..b5dd556d --- /dev/null +++ b/src/widget/responsive_menu_bar.rs @@ -0,0 +1,160 @@ +use std::collections::HashMap; + +use apply::Apply; + +use crate::{ + Core, Element, + widget::{button, icon, responsive_container}, +}; + +use super::menu::{self, ItemHeight, ItemWidth}; + +#[must_use] +pub fn responsive_menu_bar() -> ResponsiveMenuBar { + ResponsiveMenuBar::default() +} + +pub struct ResponsiveMenuBar { + collapsed_item_width: ItemWidth, + item_width: ItemWidth, + item_height: ItemHeight, + spacing: f32, +} + +impl Default for ResponsiveMenuBar { + fn default() -> ResponsiveMenuBar { + ResponsiveMenuBar { + collapsed_item_width: { + #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + if matches!( + crate::app::cosmic::WINDOWING_SYSTEM.get(), + Some(crate::app::cosmic::WindowingSystem::Wayland) + ) { + ItemWidth::Static(150) + } else { + ItemWidth::Static(84) + } + #[cfg(not(all(feature = "winit", feature = "wayland", target_os = "linux")))] + { + ItemWidth::Static(84) + } + }, + item_width: ItemWidth::Uniform(150), + item_height: ItemHeight::Uniform(30), + spacing: 0., + } + } +} + +impl ResponsiveMenuBar { + /// Set the item width + #[must_use] + pub fn item_width(mut self, item_width: ItemWidth) -> Self { + self.item_width = item_width; + self + } + + /// Set the item height + #[must_use] + pub fn item_height(mut self, item_height: ItemHeight) -> Self { + self.item_height = item_height; + self + } + + /// Set the spacing + #[must_use] + pub fn spacing(mut self, spacing: f32) -> Self { + self.spacing = spacing; + self + } + + /// # Panics + /// + /// Will panic if the menu bar collapses without tracking the size + pub fn into_element< + 'a, + Message: Clone + 'static, + A: menu::Action + Clone, + S: Into> + 'static, + >( + self, + core: &Core, + key_binds: &HashMap, + id: crate::widget::Id, + action_message: impl Fn(crate::surface::Action) -> Message + Send + Sync + Clone + 'static, + trees: Vec<(S, Vec>)>, + ) -> Element<'a, Message> { + use crate::widget::id_container; + + let menu_bar_size = core.menu_bars.get(&id); + + #[allow(clippy::if_not_else)] + if !menu_bar_size.is_some_and(|(limits, size)| { + let max_size = limits.max(); + max_size.width < size.width + }) { + responsive_container::responsive_container( + id_container( + menu::bar( + trees + .into_iter() + .map(|mt: (S, Vec>)| { + menu::Tree::<_>::with_children( + crate::widget::RcElementWrapper::new(Element::from( + menu::root(mt.0), + )), + menu::items(key_binds, mt.1), + ) + }) + .collect(), + ) + .item_width(self.item_width) + .item_height(self.item_height) + .spacing(self.spacing) + .on_surface_action(action_message.clone()) + .window_id_maybe(core.main_window_id()), + crate::widget::Id::new(format!("menu_bar_expanded_{id}")), + ), + id, + action_message, + ) + .apply(Element::from) + } else { + responsive_container::responsive_container( + id_container( + menu::bar(vec![menu::Tree::<_>::with_children( + Element::from( + button::icon(icon::from_name("open-menu-symbolic")) + .padding([4, 12]) + .class(crate::theme::Button::MenuRoot), + ), + menu::items( + key_binds, + trees + .into_iter() + .map(|mt| menu::Item::Folder(mt.0, mt.1)) + .collect(), + ) + .into_iter() + .map(|t| { + t.width(match self.item_width { + ItemWidth::Uniform(w) | ItemWidth::Static(w) => w, + }) + }) + .collect(), + )]) + .item_height(self.item_height) + .item_width(self.collapsed_item_width) + .spacing(self.spacing) + .on_surface_action(action_message.clone()) + .window_id_maybe(core.main_window_id()), + crate::widget::Id::new(format!("menu_bar_collapsed_{id}")), + ), + id, + action_message, + ) + .size(menu_bar_size.unwrap().1) + .apply(Element::from) + } + } +} diff --git a/src/widget/scrollable.rs b/src/widget/scrollable.rs deleted file mode 100644 index ca6b445e..00000000 --- a/src/widget/scrollable.rs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2022 System76 -// SPDX-License-Identifier: MPL-2.0 - -use crate::{Element, Renderer}; -use iced::widget; - -pub fn scrollable<'a, Message>( - element: impl Into>, -) -> widget::Scrollable<'a, Message, crate::Theme, Renderer> { - widget::scrollable(element) - // .scrollbar_width(8) TODO add these back - // .scroller_width(8) -} diff --git a/src/widget/scrollable/mod.rs b/src/widget/scrollable/mod.rs new file mode 100644 index 00000000..2485edf4 --- /dev/null +++ b/src/widget/scrollable/mod.rs @@ -0,0 +1,6 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +mod scrollable; + +pub use scrollable::{horizontal, scrollable, vertical}; diff --git a/src/widget/scrollable/scrollable.rs b/src/widget/scrollable/scrollable.rs new file mode 100644 index 00000000..a3fa4edd --- /dev/null +++ b/src/widget/scrollable/scrollable.rs @@ -0,0 +1,31 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::{Element, Renderer}; +use iced::widget; + +pub fn scrollable<'a, Message>( + element: impl Into>, +) -> widget::Scrollable<'a, Message, crate::Theme, Renderer> { + vertical(element) +} + +pub fn vertical<'a, Message>( + element: impl Into>, +) -> widget::Scrollable<'a, Message, crate::Theme, Renderer> { + widget::scrollable(element) + .scroller_width(8.0) + .scrollbar_width(8.0) + .scrollbar_padding(8.0) +} + +pub fn horizontal<'a, Message>( + element: impl Into>, +) -> widget::Scrollable<'a, Message, crate::Theme, Renderer> { + widget::scrollable(element) + .direction(widget::scrollable::Direction::Horizontal( + widget::scrollable::Scrollbar::new(), + )) + .scroller_width(8.0) + .scrollbar_width(8.0) +} diff --git a/src/widget/segmented_button/horizontal.rs b/src/widget/segmented_button/horizontal.rs index a859b2ca..5fd67649 100644 --- a/src/widget/segmented_button/horizontal.rs +++ b/src/widget/segmented_button/horizontal.rs @@ -23,19 +23,21 @@ pub struct Horizontal; /// For details on the model, see the [`segmented_button`](super) module for more details. pub fn horizontal( model: &Model, -) -> SegmentedButton +) -> SegmentedButton<'_, Horizontal, SelectionMode, Message> where Model: Selectable, { SegmentedButton::new(model) } -impl<'a, SelectionMode, Message> SegmentedVariant - for SegmentedButton<'a, Horizontal, SelectionMode, Message> +impl SegmentedVariant + for SegmentedButton<'_, Horizontal, SelectionMode, Message> where Model: Selectable, SelectionMode: Default, { + const VERTICAL: bool = false; + fn variant_appearance( theme: &crate::Theme, style: &crate::theme::SegmentedButton, @@ -65,7 +67,7 @@ where / num as f32; } - let segmetned_control = matches!(self.style, crate::theme::SegmentedButton::Control); + let is_control = matches!(self.style, crate::theme::SegmentedButton::Control); Box::new( self.model @@ -91,7 +93,7 @@ where let button_bounds = ItemBounds::Button(key, layout_bounds); let mut divider = None; - if self.dividers && segmetned_control && nth + 1 < num { + if self.dividers && is_control && nth + 1 < num { divider = Some(ItemBounds::Divider( Rectangle { width: 1.0, @@ -141,7 +143,7 @@ where let max_size = limits.height(Length::Fixed(max_height)).resolve( Length::Fill, max_height, - Size::new(f32::MAX, max_height), + Size::new(limits.max().width, max_height), ); let mut visible_width = 0.0; @@ -150,7 +152,7 @@ where for (button_size, _actual_size) in &state.internal_layout { visible_width += button_size.width; - if max_size.width >= visible_width { + if max_size.width - spacing >= visible_width { state.buttons_visible += 1; } else { visible_width = max_size.width - max_height; @@ -211,6 +213,18 @@ where state.buttons_offset = num - state.buttons_visible; } + // Resize paragraph bounds so that text ellipsis can take effect. + if !matches!(self.width, Length::Shrink) || state.collapsed { + let num = state.buttons_visible.max(1) as f32; + let spacing = f32::from(self.spacing); + let mut width_offset = 0.0; + if state.collapsed { + width_offset = f32::from(self.button_height) * 2.0; + } + let button_width = ((num).mul_add(-spacing, size.width - width_offset) + spacing) / num; + self.resize_paragraphs(state, button_width); + } + size } } diff --git a/src/widget/segmented_button/mod.rs b/src/widget/segmented_button/mod.rs index 34b6ec74..81c71be8 100644 --- a/src/widget/segmented_button/mod.rs +++ b/src/widget/segmented_button/mod.rs @@ -79,14 +79,27 @@ mod style; mod vertical; mod widget; -pub use self::horizontal::{horizontal, HorizontalSegmentedButton}; +pub use self::horizontal::{HorizontalSegmentedButton, horizontal}; pub use self::model::{ BuilderEntity, Entity, EntityMut, Model, ModelBuilder, MultiSelect, MultiSelectEntityMut, MultiSelectModel, Selectable, SingleSelect, SingleSelectEntityMut, SingleSelectModel, }; pub use self::style::{Appearance, ItemAppearance, ItemStatusAppearance, StyleSheet}; -pub use self::vertical::{vertical, VerticalSegmentedButton}; -pub use self::widget::{focus, Id, SegmentedButton, SegmentedVariant}; +pub use self::vertical::{VerticalSegmentedButton, vertical}; +pub use self::widget::{Id, SegmentedButton, SegmentedVariant, focus}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InsertPosition { + Before, + After, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReorderEvent { + pub dragged: Entity, + pub target: Entity, + pub position: InsertPosition, +} /// Associates extra data with an external secondary map. /// diff --git a/src/widget/segmented_button/model/builder.rs b/src/widget/segmented_button/model/builder.rs index c7e3239e..7e17f706 100644 --- a/src/widget/segmented_button/model/builder.rs +++ b/src/widget/segmented_button/model/builder.rs @@ -25,13 +25,14 @@ where #[must_use] pub fn insert( mut self, - builder: impl Fn(BuilderEntity) -> BuilderEntity, + builder: impl FnOnce(BuilderEntity) -> BuilderEntity, ) -> Self { let id = self.0.insert().id(); builder(BuilderEntity { model: self, id }).model } /// Consumes the builder and returns the model. + #[inline] pub fn build(self) -> Model { self.0 } @@ -43,6 +44,7 @@ where { /// Activates the newly-inserted item. #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn activate(mut self) -> Self { self.model.0.activate(self.id); self @@ -50,6 +52,7 @@ where /// Defines that the close button should appear #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn closable(mut self) -> Self { self.model.0.closable_set(self.id, true); self @@ -60,6 +63,7 @@ where /// The secondary map internally uses a `Vec`, so should only be used for data that /// is commonly associated. #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn secondary(self, map: &mut SecondaryMap, data: Data) -> Self { map.insert(self.id, data); self @@ -69,6 +73,7 @@ where /// /// Sparse maps internally use a `HashMap`, for data that is sparsely associated. #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn secondary_sparse( self, map: &mut SparseSecondaryMap, @@ -90,11 +95,13 @@ where /// .build() /// ``` #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn data(mut self, data: Data) -> Self { self.model.0.data_set(self.id, data); self } + #[inline] pub fn divider_above(mut self) -> Self { self.model.0.divider_above_set(self.id, true); self @@ -115,6 +122,7 @@ where /// Define the position of the newly-inserted item. #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn position(mut self, position: u16) -> Self { self.model.0.position_set(self.id, position); self @@ -122,6 +130,7 @@ where /// Swap the position with another item in the model. #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn position_swap(mut self, other: Entity) -> Self { self.model.0.position_swap(self.id, other); self diff --git a/src/widget/segmented_button/model/entity.rs b/src/widget/segmented_button/model/entity.rs index 77f591b9..a3821244 100644 --- a/src/widget/segmented_button/model/entity.rs +++ b/src/widget/segmented_button/model/entity.rs @@ -15,7 +15,7 @@ pub struct EntityMut<'a, SelectionMode: Default> { pub(super) model: &'a mut Model, } -impl<'a, SelectionMode: Default> EntityMut<'a, SelectionMode> +impl EntityMut<'_, SelectionMode> where Model: Selectable, { @@ -25,6 +25,7 @@ where /// model.insert().text("Item A").activate(); /// ``` #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn activate(self) -> Self { self.model.activate(self.id); self @@ -40,6 +41,7 @@ where /// model.insert().text("Item A").secondary(&mut secondary_data, String::new("custom data")); /// ``` #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn secondary(self, map: &mut SecondaryMap, data: Data) -> Self { map.insert(self.id, data); self @@ -54,6 +56,7 @@ where /// model.insert().text("Item A").secondary(&mut secondary_data, String::new("custom data")); /// ``` #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn secondary_sparse( self, map: &mut SparseSecondaryMap, @@ -65,6 +68,7 @@ where /// Shows a close button for this item. #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn closable(self) -> Self { self.model.closable_set(self.id, true); self @@ -78,12 +82,14 @@ where /// model.insert().text("Item A").data(String::from("custom string")); /// ``` #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn data(self, data: Data) -> Self { self.model.data_set(self.id, data); self } #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn divider_above(self, divider_above: bool) -> Self { self.model.divider_above_set(self.id, divider_above); self @@ -95,6 +101,7 @@ where /// model.insert().text("Item A").icon(IconSource::from("icon-a")); /// ``` #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn icon(self, icon: impl Into) -> Self { self.model.icon_set(self.id, icon.into()); self @@ -106,11 +113,13 @@ where /// let id = model.insert("Item A").id(); /// ``` #[must_use] - pub fn id(self) -> Entity { + #[inline] + pub const fn id(self) -> Entity { self.id } #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn indent(self, indent: u16) -> Self { self.model.indent_set(self.id, indent); self @@ -118,6 +127,7 @@ where /// Define the position of the item. #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn position(self, position: u16) -> Self { self.model.position_set(self.id, position); self @@ -125,6 +135,7 @@ where /// Swap the position with another item in the model. #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + #[inline] pub fn position_swap(self, other: Entity) -> Self { self.model.position_swap(self.id, other); self diff --git a/src/widget/segmented_button/model/mod.rs b/src/widget/segmented_button/model/mod.rs index f790bff5..e0dd8c54 100644 --- a/src/widget/segmented_button/model/mod.rs +++ b/src/widget/segmented_button/model/mod.rs @@ -11,6 +11,7 @@ mod selection; pub use self::selection::{MultiSelect, Selectable, SingleSelect}; use crate::widget::Icon; +use crate::widget::segmented_button::InsertPosition; use slotmap::{SecondaryMap, SlotMap}; use std::any::{Any, TypeId}; use std::borrow::Cow; @@ -89,11 +90,13 @@ where /// ```ignore /// model.activate(id); /// ``` + #[inline] pub fn activate(&mut self, id: Entity) { Selectable::activate(self, id); } /// Activates the item at the given position, returning true if it was activated. + #[inline] pub fn activate_position(&mut self, position: u16) -> bool { if let Some(entity) = self.entity_at(position) { self.activate(entity); @@ -113,6 +116,7 @@ where /// .build(); /// ``` #[must_use] + #[inline] pub fn builder() -> ModelBuilder { ModelBuilder::default() } @@ -126,6 +130,7 @@ where /// ```ignore /// model.clear(); /// ``` + #[inline] pub fn clear(&mut self) { for entity in self.order.clone() { self.remove(entity); @@ -133,6 +138,7 @@ where } /// Shows or hides the item's close button. + #[inline] pub fn closable_set(&mut self, id: Entity, closable: bool) { if let Some(settings) = self.items.get_mut(id) { settings.closable = closable; @@ -146,6 +152,7 @@ where /// println!("ID is still valid"); /// } /// ``` + #[inline] pub fn contains_item(&self, id: Entity) -> bool { self.items.contains_key(id) } @@ -203,6 +210,7 @@ where .and_then(|storage| storage.remove(id)); } + #[inline] pub fn divider_above(&self, id: Entity) -> Option { self.divider_aboves.get(id).copied() } @@ -215,6 +223,7 @@ where self.divider_aboves.insert(id, divider_above) } + #[inline] pub fn divider_above_remove(&mut self, id: Entity) -> Option { self.divider_aboves.remove(id) } @@ -224,6 +233,7 @@ where /// ```ignore /// model.enable(id, true); /// ``` + #[inline] pub fn enable(&mut self, id: Entity, enable: bool) { if let Some(e) = self.items.get_mut(id) { e.enabled = enable; @@ -232,6 +242,7 @@ where /// Get the item that is located at a given position. #[must_use] + #[inline] pub fn entity_at(&mut self, position: u16) -> Option { self.order.get(position as usize).copied() } @@ -243,6 +254,7 @@ where /// println!("has icon: {:?}", icon); /// } /// ``` + #[inline] pub fn icon(&self, id: Entity) -> Option<&Icon> { self.icons.get(id) } @@ -254,6 +266,7 @@ where /// println!("previously had icon: {:?}", old_icon); /// } /// ``` + #[inline] pub fn icon_set(&mut self, id: Entity, icon: Icon) -> Option { if !self.contains_item(id) { return None; @@ -268,6 +281,7 @@ where /// if let Some(old_icon) = model.icon_remove(id) { /// println!("previously had icon: {:?}", old_icon); /// } + #[inline] pub fn icon_remove(&mut self, id: Entity) -> Option { self.icons.remove(id) } @@ -278,7 +292,8 @@ where /// let id = model.insert().text("Item A").icon("custom-icon").id(); /// ``` #[must_use] - pub fn insert(&mut self) -> EntityMut { + #[inline] + pub fn insert(&mut self) -> EntityMut<'_, SelectionMode> { let id = self.items.insert(Settings::default()); self.order.push_back(id); EntityMut { model: self, id } @@ -286,14 +301,16 @@ where /// Check if the given ID is the active ID. #[must_use] + #[inline] pub fn is_active(&self, id: Entity) -> bool { ::is_active(self, id) } /// Whether the item should contain a close button. #[must_use] + #[inline] pub fn is_closable(&self, id: Entity) -> bool { - self.items.get(id).map_or(false, |e| e.closable) + self.items.get(id).map(|e| e.closable).unwrap_or_default() } /// Check if the item is enabled. @@ -306,8 +323,15 @@ where /// } /// ``` #[must_use] + #[inline] pub fn is_enabled(&self, id: Entity) -> bool { - self.items.get(id).map_or(false, |e| e.enabled) + self.items.get(id).map(|e| e.enabled).unwrap_or_default() + } + + /// Get number of items in the model. + #[inline] + pub fn len(&self) -> usize { + self.order.len() } /// Iterates across items in the model in the order that they are displayed. @@ -315,10 +339,12 @@ where self.order.iter().copied() } + #[inline] pub fn indent(&self, id: Entity) -> Option { self.indents.get(id).copied() } + #[inline] pub fn indent_set(&mut self, id: Entity, indent: u16) -> Option { if !self.contains_item(id) { return None; @@ -327,6 +353,7 @@ where self.indents.insert(id, indent) } + #[inline] pub fn indent_remove(&mut self, id: Entity) -> Option { self.indents.remove(id) } @@ -338,6 +365,7 @@ where /// println!("found item at {}", position); /// } #[must_use] + #[inline] pub fn position(&self, id: Entity) -> Option { #[allow(clippy::cast_possible_truncation)] self.order.iter().position(|k| *k == id).map(|v| v as u16) @@ -351,13 +379,12 @@ where /// } /// ``` pub fn position_set(&mut self, id: Entity, position: u16) -> Option { - let Some(index) = self.position(id) else { - return None; - }; + let index = self.position(id)?; + + self.order.remove(index as usize); let position = self.order.len().min(position as usize); - self.order.remove(index as usize); self.order.insert(position, id); Some(position) } @@ -384,6 +411,36 @@ where true } + /// Reorder `dragged` relative to `target` based on the provided position. + /// + /// Returns `true` if the model changed, or `false` if the move was invalid. + pub fn reorder(&mut self, dragged: Entity, target: Entity, position: InsertPosition) -> bool { + if !self.contains_item(dragged) || !self.contains_item(target) || dragged == target { + return false; + } + + let len = self.iter().count(); + let target_pos = self.position(target).map(|pos| pos as usize).unwrap_or(len); + let from_pos = self + .position(dragged) + .map(|pos| pos as usize) + .unwrap_or(target_pos); + let mut insert_pos = match position { + InsertPosition::Before => target_pos, + InsertPosition::After => target_pos.saturating_add(1), + }; + if from_pos < insert_pos { + insert_pos = insert_pos.saturating_sub(1); + } + if len > 0 { + insert_pos = insert_pos.min(len.saturating_sub(1)); + } + + self.position_set(dragged, insert_pos as u16); + self.activate(dragged); + true + } + /// Removes an item from the model. /// /// The generation of the slot for the ID will be incremented, so this ID will no @@ -409,6 +466,7 @@ where /// println!("{:?} has text {text}", id); /// } /// ``` + #[inline] pub fn text(&self, id: Entity) -> Option<&str> { self.text.get(id).map(Cow::as_ref) } @@ -420,7 +478,11 @@ where /// println!("{:?} had text {}", id, old_text) /// } /// ``` - pub fn text_set(&mut self, id: Entity, text: impl Into>) -> Option> { + pub fn text_set( + &mut self, + id: Entity, + text: impl Into>, + ) -> Option> { if !self.contains_item(id) { return None; } @@ -433,7 +495,48 @@ where /// if let Some(old_text) = model.text_remove(id) { /// println!("{:?} had text {}", id, old_text); /// } + #[inline] pub fn text_remove(&mut self, id: Entity) -> Option> { self.text.remove(id) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_model() -> (Model, Vec) { + let mut ids = Vec::new(); + let model = Model::builder() + .insert(|b| b.text("Tab1").with_id(|id| ids.push(id))) + .insert(|b| b.text("Tab2").with_id(|id| ids.push(id))) + .insert(|b| b.text("Tab3").with_id(|id| ids.push(id))) + .insert(|b| b.text("Tab4").with_id(|id| ids.push(id))) + .build(); + (model, ids) + } + + fn order_of(model: &Model) -> Vec { + model.iter().collect() + } + + #[test] + fn reorder_inserts_before_target() { + let (mut model, ids) = sample_model(); + assert!(model.reorder(ids[3], ids[1], InsertPosition::Before)); + assert_eq!(order_of(&model), vec![ids[0], ids[3], ids[1], ids[2]]); + } + + #[test] + fn reorder_inserts_after_target() { + let (mut model, ids) = sample_model(); + assert!(model.reorder(ids[0], ids[2], InsertPosition::After)); + assert_eq!(order_of(&model), vec![ids[1], ids[2], ids[0], ids[3]]); + } + + #[test] + fn reorder_rejects_invalid_entities() { + let (mut model, ids) = sample_model(); + assert!(!model.reorder(ids[0], ids[0], InsertPosition::After)); + } +} diff --git a/src/widget/segmented_button/model/selection.rs b/src/widget/segmented_button/model/selection.rs index 1366c18c..c0927652 100644 --- a/src/widget/segmented_button/model/selection.rs +++ b/src/widget/segmented_button/model/selection.rs @@ -39,6 +39,7 @@ impl Selectable for Model { } } + #[inline] fn is_active(&self, id: Entity) -> bool { self.selection.active == id } @@ -47,23 +48,27 @@ impl Selectable for Model { impl Model { /// Get an immutable reference to the data associated with the active item. #[must_use] + #[inline] pub fn active_data(&self) -> Option<&Data> { self.data(self.active()) } /// Get a mutable reference to the data associated with the active item. #[must_use] + #[inline] pub fn active_data_mut(&mut self) -> Option<&mut Data> { self.data_mut(self.active()) } /// Deactivates the active item. + #[inline] pub fn deactivate(&mut self) { Selectable::deactivate(self, Entity::default()); } /// The ID of the active item. #[must_use] + #[inline] pub fn active(&self) -> Entity { self.selection.active } @@ -86,10 +91,12 @@ impl Selectable for Model { } } + #[inline] fn deactivate(&mut self, id: Entity) { self.selection.active.remove(&id); } + #[inline] fn is_active(&self, id: Entity) -> bool { self.selection.active.contains(&id) } @@ -97,11 +104,13 @@ impl Selectable for Model { impl Model { /// Deactivates the item in the model. + #[inline] pub fn deactivate(&mut self, id: Entity) { Selectable::deactivate(self, id); } /// The IDs of the active items. + #[inline] pub fn active(&self) -> impl Iterator + '_ { self.selection.active.iter().copied() } diff --git a/src/widget/segmented_button/style.rs b/src/widget/segmented_button/style.rs index 30fa3363..4aa856ef 100644 --- a/src/widget/segmented_button/style.rs +++ b/src/widget/segmented_button/style.rs @@ -1,31 +1,25 @@ // Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 -use iced_core::{border::Radius, Background, Color}; +use iced::Border; +use iced_core::{Background, Color}; /// Appearance of the segmented button. #[derive(Default, Clone, Copy)] pub struct Appearance { pub background: Option, - pub border_radius: Radius, - pub border_bottom: Option<(f32, Color)>, - pub border_end: Option<(f32, Color)>, - pub border_start: Option<(f32, Color)>, - pub border_top: Option<(f32, Color)>, + pub border: Border, + pub active_width: f32, pub active: ItemStatusAppearance, pub inactive: ItemStatusAppearance, pub hover: ItemStatusAppearance, - pub focus: ItemStatusAppearance, + pub pressed: ItemStatusAppearance, } /// Appearance of an item in the segmented button. #[derive(Default, Clone, Copy)] pub struct ItemAppearance { - pub border_radius: Radius, - pub border_bottom: Option<(f32, Color)>, - pub border_end: Option<(f32, Color)>, - pub border_start: Option<(f32, Color)>, - pub border_top: Option<(f32, Color)>, + pub border: Border, } /// Appearance of an item based on its status. diff --git a/src/widget/segmented_button/vertical.rs b/src/widget/segmented_button/vertical.rs index d8ae0be9..5458cd0a 100644 --- a/src/widget/segmented_button/vertical.rs +++ b/src/widget/segmented_button/vertical.rs @@ -22,7 +22,7 @@ pub type VerticalSegmentedButton<'a, SelectionMode, Message> = /// For details on the model, see the [`segmented_button`](super) module for more details. pub fn vertical( model: &Model, -) -> SegmentedButton +) -> SegmentedButton<'_, Vertical, SelectionMode, Message> where Model: Selectable, SelectionMode: Default, @@ -30,12 +30,14 @@ where SegmentedButton::new(model) } -impl<'a, SelectionMode, Message> SegmentedVariant - for SegmentedButton<'a, Vertical, SelectionMode, Message> +impl SegmentedVariant + for SegmentedButton<'_, Vertical, SelectionMode, Message> where Model: Selectable, SelectionMode: Default, { + const VERTICAL: bool = true; + fn variant_appearance( theme: &crate::Theme, style: &crate::theme::SegmentedButton, @@ -115,10 +117,15 @@ where height += item_height; } - limits.height(Length::Fixed(height)).resolve( + let size = limits.height(Length::Fixed(height)).resolve( self.width, self.height, Size::new(width, height), - ) + ); + + // Resize paragraph bounds so that text ellipsis can take effect. + self.resize_paragraphs(state, size.width); + + size } } diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index f070c7e7..44ca8574 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -2,40 +2,52 @@ // SPDX-License-Identifier: MPL-2.0 use super::model::{Entity, Model, Selectable}; -use crate::iced_core::id::Internal; +use super::{InsertPosition, ReorderEvent}; use crate::theme::{SegmentedButton as Style, THEME}; use crate::widget::dnd_destination::DragId; use crate::widget::menu::{ - self, menu_roots_children, menu_roots_diff, CloseCondition, ItemHeight, ItemWidth, - MenuBarState, PathHighlight, + self, CloseCondition, ItemHeight, ItemWidth, MenuBarState, PathHighlight, menu_roots_children, + menu_roots_diff, }; -use crate::widget::{icon, Icon}; +use crate::widget::{Icon, icon}; use crate::{Element, Renderer}; use derive_setters::Setters; -use iced::clipboard::dnd::{self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent}; +use iced::clipboard::dnd::{ + self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent, SourceEvent, +}; use iced::clipboard::mime::AllowedMimeTypes; use iced::touch::Finger; use iced::{ - alignment, event, keyboard, mouse, touch, Alignment, Background, Color, Command, Event, Length, - Padding, Rectangle, Size, + Alignment, Background, Color, Event, Length, Padding, Rectangle, Size, Task, Vector, alignment, + keyboard, mouse, touch, window, }; +use iced_core::id::Internal; use iced_core::mouse::ScrollDelta; -use iced_core::text::{LineHeight, Paragraph, Renderer as TextRenderer, Shaping, Wrap}; +use iced_core::text::{self, Ellipsize, LineHeight, Renderer as TextRenderer, Shaping, Wrapping}; +use iced_core::widget::operation::Focusable; use iced_core::widget::{self, operation, tree}; -use iced_core::{layout, renderer, widget::Tree, Clipboard, Layout, Shell, Widget}; -use iced_core::{Border, Gradient, Point, Renderer as IcedRenderer, Shadow, Text}; +use iced_core::{Border, Point, Renderer as IcedRenderer, Shadow, Text}; +use iced_core::{Clipboard, Layout, Shell, Widget, layout, renderer, widget::Tree}; +use iced_runtime::{Action, task}; use slotmap::{Key, SecondaryMap}; use std::borrow::Cow; -use std::collections::hash_map::DefaultHasher; +use std::cell::{Cell, LazyCell}; use std::collections::HashSet; +use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; -use std::mem; use std::time::{Duration, Instant}; +thread_local! { + // Prevents two segmented buttons from being focused at the same time. + static LAST_FOCUS_UPDATE: LazyCell> = LazyCell::new(|| Cell::new(Instant::now())); +} + +const TAB_REORDER_LOG_TARGET: &str = "libcosmic::widget::tab_reorder"; + /// A command that focuses a segmented item stored in a widget. -pub fn focus(id: Id) -> Command { - Command::widget(operation::focusable::focus(id.0)) +pub fn focus(id: Id) -> Task { + task::effect(Action::Widget(Box::new(operation::focusable::focus(id.0)))) } pub enum ItemBounds { @@ -43,8 +55,31 @@ pub enum ItemBounds { Divider(Rectangle, bool), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DropSide { + Before, + After, +} + +impl From for InsertPosition { + fn from(side: DropSide) -> Self { + match side { + DropSide::Before => InsertPosition::Before, + DropSide::After => InsertPosition::After, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DropHint { + entity: Entity, + side: DropSide, +} + /// Isolates variant-specific behaviors from [`SegmentedButton`]. pub trait SegmentedVariant { + const VERTICAL: bool; + /// Get the appearance for this variant of the widget. fn variant_appearance( theme: &crate::Theme, @@ -106,11 +141,11 @@ where /// Spacing for each indent. pub(super) indent_spacing: u16, /// Desired font for active tabs. - pub(super) font_active: Option, + pub(super) font_active: crate::font::Font, /// Desired font for hovered tabs. - pub(super) font_hovered: Option, + pub(super) font_hovered: crate::font::Font, /// Desired font for inactive tabs. - pub(super) font_inactive: Option, + pub(super) font_inactive: crate::font::Font, /// Size of the font. pub(super) font_size: f32, /// Desired width of the widget. @@ -121,12 +156,14 @@ where pub(super) spacing: u16, /// LineHeight of the font. pub(super) line_height: LineHeight, + /// Ellipsize strategy for button text. + pub(super) ellipsize: Ellipsize, /// Style to draw the widget in. #[setters(into)] pub(super) style: Style, /// The context menu to display when a context is activated #[setters(skip)] - pub(super) context_menu: Option>>, + pub(super) context_menu: Option>>, /// Emits the ID of the item that was activated. #[setters(skip)] pub(super) on_activate: Option Message + 'static>>, @@ -147,6 +184,12 @@ where #[setters(strip_option)] pub(super) drag_id: Option, #[setters(skip)] + pub(super) tab_drag: Option>, + #[setters(skip)] + pub(super) on_drop_hint: Option) -> Message + 'static>>, + #[setters(skip)] + pub(super) on_reorder: Option Message + 'static>>, + #[setters(skip)] /// Defines the implementation of this struct variant: PhantomData, } @@ -157,6 +200,7 @@ where Model: Selectable, SelectionMode: Default, { + #[inline] pub fn new(model: &'a Model) -> Self { Self { model, @@ -173,14 +217,15 @@ where minimum_button_width: u16::MIN, maximum_button_width: u16::MAX, indent_spacing: 16, - font_active: None, - font_hovered: None, - font_inactive: None, + font_active: crate::font::semibold(), + font_hovered: crate::font::default(), + font_inactive: crate::font::default(), font_size: 14.0, height: Length::Shrink, width: Length::Fill, spacing: 0, line_height: LineHeight::default(), + ellipsize: Ellipsize::default(), style: Style::default(), context_menu: None, on_activate: None, @@ -193,16 +238,75 @@ where mimes: Vec::new(), variant: PhantomData, drag_id: None, + tab_drag: None, + on_drop_hint: None, + on_reorder: None, } } - pub fn context_menu(mut self, context_menu: Option>>) -> Self + fn update_entity_paragraph(&mut self, state: &mut LocalState, key: Entity) { + if let Some(text) = self.model.text.get(key) { + let font = if self.button_is_focused(state, key) + || state.show_context == Some(key) + || self.model.is_active(key) + { + self.font_active + } else if self.button_is_hovered(state, key) { + self.font_hovered + } else { + self.font_inactive + }; + + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + font.hash(&mut hasher); + let text_hash = hasher.finish(); + + if let Some(prev_hash) = state.text_hashes.insert(key, text_hash) + && prev_hash == text_hash + { + return; + } + + if let Some(paragraph) = state.paragraphs.get_mut(key) { + let text = Text { + content: text.as_ref(), + size: iced::Pixels(self.font_size), + bounds: Size::INFINITE, + font, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, + shaping: Shaping::Advanced, + wrapping: Wrapping::None, + line_height: self.line_height, + ellipsize: self.ellipsize, + }; + paragraph.update(text); + } else { + let text = Text { + content: text.to_string(), + size: iced::Pixels(self.font_size), + bounds: Size::INFINITE, + font, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, + shaping: Shaping::Advanced, + wrapping: Wrapping::None, + line_height: self.line_height, + ellipsize: self.ellipsize, + }; + state.paragraphs.insert(key, crate::Plain::new(text)); + } + } + } + + pub fn context_menu(mut self, context_menu: Option>>) -> Self where - Message: 'static, + Message: Clone + 'static, { self.context_menu = context_menu.map(|menus| { vec![menu::Tree::with_children( - crate::widget::row::<'static, Message>(), + crate::Element::from(crate::widget::Row::new()), menus, )] }); @@ -250,9 +354,77 @@ where self } + /// Enable drag-and-drop support for tabs using the provided payload builder. + pub fn enable_tab_drag(mut self, mime: String) -> Self { + self.tab_drag = Some(TabDragSource::new(mime)); + self + } + + /// Receive drop hint updates during drag-and-drop. + pub fn on_drop_hint( + mut self, + callback: impl Fn(Option<(Entity, bool)>) -> Message + 'static, + ) -> Self { + self.on_drop_hint = Some(Box::new(callback)); + self + } + + /// Emit a message when a tab drag is dropped inside this widget. + pub fn on_reorder(mut self, callback: impl Fn(ReorderEvent) -> Message + 'static) -> Self { + self.on_reorder = Some(Box::new(callback)); + self + } + + /// Set the pointer distance threshold before a drag is started. + pub fn tab_drag_threshold(mut self, threshold: f32) -> Self { + if let Some(tab_drag) = self.tab_drag.as_mut() { + tab_drag.threshold = threshold.max(1.0); + } + self + } + + fn reorder_event_for_drop(&self, state: &LocalState, target: Entity) -> Option { + let dragged = state.dragging_tab?; + if dragged == target + || !self.model.contains_item(dragged) + || !self.model.contains_item(target) + { + return None; + } + let position = state + .drop_hint + .filter(|hint| hint.entity == target) + .map(|hint| InsertPosition::from(hint.side)) + .unwrap_or_else(|| self.default_insert_position(dragged, target)); + Some(ReorderEvent { + dragged, + target, + position, + }) + } + + fn default_insert_position(&self, dragged: Entity, target: Entity) -> InsertPosition { + let len = self.model.len(); + let target_pos = self + .model + .position(target) + .map(|pos| pos as usize) + .unwrap_or(len); + let from_pos = self + .model + .position(dragged) + .map(|pos| pos as usize) + .unwrap_or(target_pos); + if from_pos < target_pos { + InsertPosition::After + } else { + InsertPosition::Before + } + } + /// Check if an item is enabled. fn is_enabled(&self, key: Entity) -> bool { - self.model.items.get(key).map_or(false, |item| item.enabled) + self.model.items.get(key).is_some_and(|item| item.enabled) } /// Handle the dnd drop event. @@ -263,7 +435,7 @@ where self.on_dnd_drop = Some(Box::new(move |entity, data, mime, action| { dnd_drop_handler(entity, D::try_from((data, mime)).ok(), action) })); - self.mimes = D::allowed().iter().cloned().collect(); + self.mimes = D::allowed().into_owned(); self } @@ -283,7 +455,7 @@ where } /// Item the previous item in the widget. - fn focus_previous(&mut self, state: &mut LocalState) -> event::Status { + fn focus_previous(&mut self, state: &mut LocalState, shell: &mut Shell<'_, Message>) { match state.focused_item { Item::Tab(entity) => { let mut keys = self.iterate_visible_tabs(state).rev(); @@ -297,7 +469,8 @@ where } state.focused_item = Item::Tab(key); - return event::Status::Captured; + shell.capture_event(); + return; } break; @@ -306,24 +479,28 @@ where if self.prev_tab_sensitive(state) { state.focused_item = Item::PrevButton; - return event::Status::Captured; + shell.capture_event(); + return; } } Item::NextButton => { if let Some(last) = self.last_tab(state) { state.focused_item = Item::Tab(last); - return event::Status::Captured; + shell.capture_event(); + return; } } Item::None => { if self.next_tab_sensitive(state) { state.focused_item = Item::NextButton; - return event::Status::Captured; + shell.capture_event(); + return; } else if let Some(last) = self.last_tab(state) { state.focused_item = Item::Tab(last); - return event::Status::Captured; + shell.capture_event(); + return; } } @@ -331,11 +508,10 @@ where } state.focused_item = Item::None; - event::Status::Ignored } /// Item the next item in the widget. - fn focus_next(&mut self, state: &mut LocalState) -> event::Status { + fn focus_next(&mut self, state: &mut LocalState, shell: &mut Shell<'_, Message>) { match state.focused_item { Item::Tab(entity) => { let mut keys = self.iterate_visible_tabs(state); @@ -348,7 +524,8 @@ where } state.focused_item = Item::Tab(key); - return event::Status::Captured; + shell.capture_event(); + return; } break; @@ -357,24 +534,28 @@ where if self.next_tab_sensitive(state) { state.focused_item = Item::NextButton; - return event::Status::Captured; + shell.capture_event(); + return; } } Item::PrevButton => { if let Some(first) = self.first_tab(state) { state.focused_item = Item::Tab(first); - return event::Status::Captured; + shell.capture_event(); + return; } } Item::None => { if self.prev_tab_sensitive(state) { state.focused_item = Item::PrevButton; - return event::Status::Captured; + shell.capture_event(); + return; } else if let Some(first) = self.first_tab(state) { state.focused_item = Item::Tab(first); - return event::Status::Captured; + shell.capture_event(); + return; } } @@ -382,7 +563,6 @@ where } state.focused_item = Item::None; - event::Status::Ignored } fn iterate_visible_tabs<'b>( @@ -432,26 +612,26 @@ where .text .get(button) .zip(state.paragraphs.entry(button)) + && !text.is_empty() { - if !text.is_empty() { - icon_spacing = f32::from(self.button_spacing); - let paragraph = entry.or_insert_with(|| { - crate::Paragraph::with_text(Text { - content: text, - size: iced::Pixels(self.font_size), - bounds: Size::INFINITY, - font, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - shaping: Shaping::Advanced, - wrap: Wrap::default(), - line_height: self.line_height, - }) - }); + icon_spacing = f32::from(self.button_spacing); + let paragraph = entry.or_insert_with(|| { + crate::Plain::new(Text { + content: text.to_string(), // TODO should we just use String at this point? + size: iced::Pixels(self.font_size), + bounds: Size::INFINITE, + font, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, + shaping: Shaping::Advanced, + wrapping: Wrapping::default(), + ellipsize: self.ellipsize, + line_height: self.line_height, + }) + }); - let size = paragraph.min_bounds(); - width += size.width; - } + let size = paragraph.min_bounds(); + width += size.width; } // Add indent to measurement if found. @@ -481,6 +661,50 @@ where (width, f32::from(self.button_height)) } + /// Resizes paragraph bounds based on the actual available button width so that + /// text ellipsis can take effect. Call this after `variant_layout` has populated + /// `state.internal_layout` with final button sizes. + pub(super) fn resize_paragraphs(&self, state: &mut LocalState, available_width: f32) { + if matches!(self.ellipsize, Ellipsize::None) { + return; + } + + for (nth, key) in self.model.order.iter().copied().enumerate() { + if self.model.text(key).is_some_and(|text| !text.is_empty()) { + let mut non_text_width = + f32::from(self.button_padding[0]) + f32::from(self.button_padding[2]); + + if let Some(icon) = self.model.icon(key) { + non_text_width += f32::from(icon.size) + f32::from(self.button_spacing); + } else if self.model.is_active(key) { + if let crate::theme::SegmentedButton::Control = self.style { + non_text_width += 16.0 + f32::from(self.button_spacing); + } + } + + if self.model.is_closable(key) { + non_text_width += + f32::from(self.close_icon.size) + f32::from(self.button_spacing); + } + + let text_width = (available_width - non_text_width).max(0.0); + + if let Some(paragraph) = state.paragraphs.get_mut(key) { + paragraph.resize(Size::new(text_width, f32::INFINITY)); + + // Update internal_layout actual content width so that + // button_alignment centering uses the ellipsized size. + let content_width = paragraph.min_bounds().width + non_text_width + - f32::from(self.button_padding[0]) + - f32::from(self.button_padding[2]); + if let Some(entry) = state.internal_layout.get_mut(nth) { + entry.1.width = content_width; + } + } + } + } + } + pub(super) fn max_button_dimensions( &self, state: &mut LocalState, @@ -516,7 +740,9 @@ where } fn button_is_focused(&self, state: &LocalState, key: Entity) -> bool { - self.on_activate.is_some() && Item::Tab(key) == state.focused_item + state.focused.is_some() + && self.on_activate.is_some() + && Item::Tab(key) == state.focused_item } fn button_is_hovered(&self, state: &LocalState, key: Entity) -> bool { @@ -528,6 +754,97 @@ where .is_some_and(|id| id.data.is_some_and(|d| d == key)) } + fn button_is_pressed(&self, state: &LocalState, key: Entity) -> bool { + state.pressed_item == Some(Item::Tab(key)) + } + + fn emit_drop_hint(&self, shell: &mut Shell<'_, Message>, hint: Option) { + if let Some(on_hint) = self.on_drop_hint.as_ref() { + let mapped = hint.map(|hint| (hint.entity, matches!(hint.side, DropSide::After))); + shell.publish(on_hint(mapped)); + } + } + + fn drop_hint_for_position( + &self, + state: &LocalState, + bounds: Rectangle, + cursor: Point, + ) -> Option { + let _ = state.dragging_tab?; + + self.variant_bounds(state, bounds) + .filter_map(|item| match item { + ItemBounds::Button(entity, rect) if rect.contains(cursor) => Some((entity, rect)), + _ => None, + }) + .map(|(entity, rect)| { + let before = if Self::VERTICAL { + cursor.y < rect.center_y() + } else { + cursor.x < rect.center_x() + }; + DropHint { + entity, + side: if before { + DropSide::Before + } else { + DropSide::After + }, + } + }) + .next() + } + + fn start_tab_drag( + &self, + state: &mut LocalState, + entity: Entity, + bounds: Rectangle, + cursor: Point, + clipboard: &mut dyn Clipboard, + ) -> bool { + let Some(tab_drag) = self.tab_drag.as_ref() else { + return false; + }; + + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "start_tab_drag requested entity={:?} cursor=({:.2},{:.2}) bounds=({:.2},{:.2},{:.2},{:.2}) threshold={}", + entity, + cursor.x, + cursor.y, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + tab_drag.threshold + ); + + let data_len = 0; + + iced_core::clipboard::start_dnd::( + clipboard, + false, + Some(iced_core::clipboard::DndSource::Widget(self.id.0.clone())), + None, + Box::new(SimpleDragData::new(tab_drag.mime.clone(), vec![1])), + DndAction::Move, + ); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "tab drag started entity={:?} mime={} bytes={}", + entity, + tab_drag.mime, + data_len + ); + + state.dragging_tab = Some(entity); + state.tab_drag_candidate = None; + state.pressed_item = None; + true + } + /// Returns the drag id of the destination. /// /// # Panics @@ -536,7 +853,7 @@ where pub fn get_drag_id(&self) -> u128 { self.drag_id.map_or_else( || { - u128::from(match &self.id.0 .0 { + u128::from(match &self.id.0.0 { Internal::Unique(id) | Internal::Custom(id, _) => *id, Internal::Set(_) => panic!("Invalid Id assigned to dnd destination."), }) @@ -546,14 +863,22 @@ where } } -impl<'a, Variant, SelectionMode, Message> Widget - for SegmentedButton<'a, Variant, SelectionMode, Message> +impl Widget + for SegmentedButton<'_, Variant, SelectionMode, Message> where Self: SegmentedVariant, Model: Selectable, SelectionMode: Default, Message: 'static + Clone, { + fn id(&self) -> Option { + Some(self.id.0.clone()) + } + + fn set_id(&mut self, id: widget::Id) { + self.id = Id(id); + } + fn children(&self) -> Vec { let mut children = Vec::new(); @@ -561,7 +886,7 @@ where if let Some(ref context_menu) = self.context_menu { let mut tree = Tree::empty(); tree.state = tree::State::new(MenuBarState::default()); - tree.children = menu_roots_children(&context_menu); + tree.children = menu_roots_children(context_menu); children.push(tree); } @@ -575,6 +900,7 @@ where fn state(&self) -> tree::State { #[allow(clippy::default_trait_access)] tree::State::new(LocalState { + menu_state: Default::default(), paragraphs: SecondaryMap::new(), text_hashes: SecondaryMap::new(), buttons_visible: Default::default(), @@ -582,6 +908,7 @@ where collapsed: Default::default(), focused: Default::default(), focused_item: Default::default(), + focused_visible: false, hovered: Default::default(), known_length: Default::default(), middle_clicked: Default::default(), @@ -591,70 +918,32 @@ where wheel_timestamp: Default::default(), dnd_state: Default::default(), fingers_pressed: Default::default(), + pressed_item: None, + tab_drag_candidate: None, + dragging_tab: None, + drop_hint: None, + offer_mimes: Vec::new(), }) } fn diff(&mut self, tree: &mut Tree) { let state = tree.state.downcast_mut::(); - for key in self.model.order.iter().copied() { - if let Some(text) = self.model.text.get(key) { - let (font, button_state) = - if self.model.is_active(key) || self.button_is_focused(state, key) { - (self.font_active, 0) - } else if self.button_is_hovered(state, key) { - (self.font_hovered, 1) - } else { - (self.font_inactive, 2) - }; - - let font = font.unwrap_or_else(crate::font::default); - let mut hasher = DefaultHasher::new(); - text.hash(&mut hasher); - font.hash(&mut hasher); - button_state.hash(&mut hasher); - let text_hash = hasher.finish(); - - if let Some(prev_hash) = state.text_hashes.insert(key, text_hash) { - if prev_hash == text_hash { - continue; - } - } - - let text = Text { - content: text, - size: iced::Pixels(self.font_size), - bounds: Size::INFINITY, - font, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - shaping: Shaping::Advanced, - wrap: Wrap::default(), - line_height: self.line_height, - }; - - if let Some(paragraph) = state.paragraphs.get_mut(key) { - paragraph.update(text); - } else { - state - .paragraphs - .insert(key, crate::Paragraph::with_text(text)); - } - } + self.update_entity_paragraph(state, key); } // Diff the context menu if let Some(context_menu) = &mut self.context_menu { - if tree.children.is_empty() { - let mut child_tree = Tree::empty(); - child_tree.state = tree::State::new(MenuBarState::default()); - tree.children.push(child_tree); - } else { - tree.children.truncate(1); - } - menu_roots_diff(context_menu, &mut tree.children[0]); - } else { - tree.children.clear(); + state.menu_state.inner.with_data_mut(|inner| { + menu_roots_diff(context_menu, &mut inner.tree); + }); + } + + // Unfocus if another segmented control was focused. + if let Some(f) = state.focused.as_ref() + && f.updated_at != LAST_FOCUS_UPDATE.with(|f| f.get()) + { + state.unfocus(); } } @@ -663,7 +952,7 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, @@ -677,20 +966,19 @@ where } #[allow(clippy::too_many_lines)] - fn on_event( + fn update( &mut self, tree: &mut Tree, - mut event: Event, + mut event: &Event, layout: Layout<'_>, cursor_position: mouse::Cursor, _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, + clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &iced::Rectangle, - ) -> event::Status { - let bounds = layout.bounds(); + ) { + let my_bounds = layout.bounds(); let state = tree.state.downcast_mut::(); - state.hovered = Item::None; let my_id = self.get_drag_id(); @@ -700,7 +988,27 @@ where .drag_offer .as_ref() .map(|dnd_state| dnd_state.data); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "segmented button {:?} received DnD event: {:?} entity={entity:?}", + my_id, + e + ); match e { + DndEvent::Source(SourceEvent::Cancelled | SourceEvent::Finished) => { + if state.dragging_tab.take().is_some() { + state.tab_drag_candidate = None; + state.drop_hint = None; + self.emit_drop_hint(shell, state.drop_hint); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "tab drag source finished id={:?}", + my_id + ); + shell.capture_event(); + return; + } + } DndEvent::Offer( id, OfferEvent::Enter { @@ -708,43 +1016,78 @@ where }, ) if Some(my_id) == *id => { let entity = self - .variant_bounds(state, bounds) + .variant_bounds(state, my_bounds) .filter_map(|item| match item { ItemBounds::Button(entity, bounds) => Some((entity, bounds)), _ => None, }) .find(|(_key, bounds)| bounds.contains(Point::new(*x as f32, *y as f32))) .map(|(key, _)| key); - - let on_dnd_enter = - self.on_dnd_enter - .as_ref() - .zip(entity.clone()) - .map(|(on_enter, entity)| { - move |_, _, mime_types| on_enter(entity, mime_types) - }); - - _ = state.dnd_state.on_enter::( - *x, - *y, - mime_types.clone(), - on_dnd_enter, - entity, + state.drop_hint = self.drop_hint_for_position( + state, + my_bounds, + Point::new(*x as f32, *y as f32), ); + self.emit_drop_hint(shell, state.drop_hint); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer enter id={my_id:?} entity={entity:?} @ ({x},{y}) mimes={mime_types:?}" + ); + // force hovered state update + if let Some(entity) = entity { + state.hovered = Item::Tab(entity); + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } + } + + let on_dnd_enter = self + .on_dnd_enter + .as_ref() + .zip(entity) + .map(|(on_enter, entity)| move |_, _, mimes| on_enter(entity, mimes)); + let mimes = if let Some(mime) = self.tab_drag.as_ref().map(|d| &d.mime) + && mime_types.is_empty() + { + vec![mime.clone()] + } else { + mime_types.clone() + }; + state.offer_mimes.clone_from(&mimes); + + _ = state + .dnd_state + .on_enter::(*x, *y, mimes, on_dnd_enter, entity); } - DndEvent::Offer(id, OfferEvent::Leave | OfferEvent::LeaveDestination) - if Some(my_id) == *id => + DndEvent::Offer(id, OfferEvent::LeaveDestination) if Some(my_id) != *id => {} + DndEvent::Offer(id, leave) + if matches!(leave, OfferEvent::Leave | OfferEvent::LeaveDestination) + && Some(my_id) == *id => { + state.drop_hint = None; + self.emit_drop_hint(shell, state.drop_hint); if let Some(Some(entity)) = entity { if let Some(on_dnd_leave) = self.on_dnd_leave.as_ref() { shell.publish(on_dnd_leave(entity)); } } + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer leave id={my_id:?} entity={entity:?}" + ); + state.hovered = Item::None; + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } _ = state.dnd_state.on_leave::(None); } DndEvent::Offer(id, OfferEvent::Motion { x, y }) if Some(my_id) == *id => { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer motion id={my_id:?} cursor=({x},{y}) current_entity={entity:?}" + ); let new = self - .variant_bounds(state, bounds) + .variant_bounds(state, my_bounds) .filter_map(|item| match item { ItemBounds::Button(entity, bounds) => Some((entity, bounds)), _ => None, @@ -759,14 +1102,24 @@ where None:: Message>, Some(new_entity), ); + state.drop_hint = self.drop_hint_for_position( + state, + my_bounds, + Point::new(*x as f32, *y as f32), + ); + self.emit_drop_hint(shell, state.drop_hint); if Some(Some(new_entity)) != entity { + state.hovered = Item::Tab(new_entity); + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } let prev_action = state .dnd_state .drag_offer .as_ref() .map(|dnd| dnd.selected_action); if let Some(on_dnd_enter) = self.on_dnd_enter.as_ref() { - shell.publish(on_dnd_enter(new_entity, Vec::new())); + shell.publish(on_dnd_enter(new_entity, state.offer_mimes.clone())); } if let Some(dnd) = state.dnd_state.drag_offer.as_mut() { dnd.data = Some(new_entity); @@ -776,6 +1129,16 @@ where } } } else if entity.is_some() { + state.hovered = Item::None; + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer motion leaving id={my_id:?}" + ); + state.drop_hint = None; + self.emit_drop_hint(shell, state.drop_hint); state.dnd_state.on_motion::( *x, *y, @@ -791,61 +1154,116 @@ where } } DndEvent::Offer(id, OfferEvent::Drop) if Some(my_id) == *id => { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer drop id={my_id:?} entity={entity:?}" + ); _ = state .dnd_state .on_drop::(None:: Message>); } DndEvent::Offer(id, OfferEvent::SelectedAction(action)) if Some(my_id) == *id => { if state.dnd_state.drag_offer.is_some() { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer selected action id={my_id:?} action={action:?} entity={entity:?}" + ); _ = state .dnd_state .on_action_selected::(*action, None:: Message>); } } DndEvent::Offer(id, OfferEvent::Data { data, mime_type }) if Some(my_id) == *id => { - if let Some(Some(entity)) = entity { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer data id={my_id:?} entity={entity:?} mime={mime_type:?}" + ); + let drop_entity = entity + .flatten() + .or_else(|| state.drop_hint.map(|hint| hint.entity)); + let allow_reorder = state + .dnd_state + .drag_offer + .as_ref() + .is_some_and(|offer| offer.selected_action.contains(DndAction::Move)); + let pending_reorder = if allow_reorder + && self.on_reorder.is_some() + && self.tab_drag.as_ref().is_some_and(|d| d.mime == *mime_type) + && state.dragging_tab.is_some() + { + drop_entity.and_then(|target| self.reorder_event_for_drop(state, target)) + } else { + None + }; + if let Some(entity) = drop_entity { let on_drop = self.on_dnd_drop.as_ref(); let on_drop = on_drop.map(|on_drop| { |mime, data, action, _, _| on_drop(entity, data, mime, action) }); - if let (Some(msg), ret) = state.dnd_state.on_data_received( - mem::take(mime_type), - mem::take(data), + let (maybe_msg, ret) = state.dnd_state.on_data_received( + mime_type.clone(), + data.clone(), None:: Message>, on_drop, - ) { - shell.publish(msg); - return ret; + ); + if matches!(ret, iced::event::Status::Captured) { + shell.capture_event(); } + if let Some(msg) = maybe_msg { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "publishing drop message entity={entity:?}" + ); + shell.publish(msg); + } + state.drop_hint = None; + + self.emit_drop_hint(shell, state.drop_hint); + if let Some(event) = pending_reorder { + state.focused_item = Item::Tab(event.dragged); + state.hovered = Item::None; + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } + if let Some(on_reorder) = self.on_reorder.as_ref() { + shell.publish(on_reorder(event)); + shell.capture_event(); + return; + } + } + return; } } _ => {} } } - if cursor_position.is_over(bounds) { + if cursor_position.is_over(my_bounds) { let fingers_pressed = state.fingers_pressed.len(); match event { Event::Touch(touch::Event::FingerPressed { id, .. }) => { - state.fingers_pressed.insert(id); + state.fingers_pressed.insert(*id); } Event::Touch(touch::Event::FingerLifted { id, .. }) => { - state.fingers_pressed.remove(&id); + state.fingers_pressed.remove(id); } - _ => (), } // Check for clicks on the previous and next tab buttons, when tabs are collapsed. if state.collapsed { // Check if the prev tab button was clicked. - if cursor_position.is_over(prev_tab_bounds(&bounds, f32::from(self.button_height))) + if cursor_position + .is_over(prev_tab_bounds(&my_bounds, f32::from(self.button_height))) && self.prev_tab_sensitive(state) { state.hovered = Item::PrevButton; + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) = event { @@ -854,11 +1272,13 @@ where } else { // Check if the next tab button was clicked. if cursor_position - .is_over(next_tab_bounds(&bounds, f32::from(self.button_height))) + .is_over(next_tab_bounds(&my_bounds, f32::from(self.button_height))) && self.next_tab_sensitive(state) { state.hovered = Item::NextButton; - + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) = event { @@ -869,7 +1289,7 @@ where } for (key, bounds) in self - .variant_bounds(state, bounds) + .variant_bounds(state, my_bounds) .filter_map(|item| match item { ItemBounds::Button(entity, bounds) => Some((entity, bounds)), _ => None, @@ -879,19 +1299,29 @@ where if cursor_position.is_over(bounds) { if self.model.items[key].enabled { // Record that the mouse is hovering over this button. - state.hovered = Item::Tab(key); + if state.hovered != Item::Tab(key) { + state.hovered = Item::Tab(key); + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } + } + + let close_button_bounds = + close_bounds(bounds, f32::from(self.close_icon.size)); + let over_close_button = self.model.items[key].closable + && cursor_position.is_over(close_button_bounds); // If marked as closable, show a close icon. if self.model.items[key].closable { // Emit close message if the close button is pressed. if let Some(on_close) = self.on_close.as_ref() { - if cursor_position - .is_over(close_bounds(bounds, f32::from(self.close_icon.size))) + if over_close_button && (left_button_released(&event) || (touch_lifted(&event) && fingers_pressed == 1)) { shell.publish(on_close(key)); - return event::Status::Captured; + shell.capture_event(); + return; } if self.on_middle_press.is_none() { @@ -902,7 +1332,8 @@ where { if state.middle_clicked == Some(Item::Tab(key)) { shell.publish(on_close(key)); - return event::Status::Captured; + shell.capture_event(); + return; } state.middle_clicked = None; @@ -911,36 +1342,69 @@ where } } + if self.tab_drag.is_some() + && matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) + && !over_close_button + && let Some(position) = cursor_position.position() + { + state.tab_drag_candidate = Some(TabDragCandidate { + entity: key, + bounds, + origin: position, + }); + if let Some(tab_drag) = self.tab_drag.as_ref() { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "tab drag candidate entity={:?} origin=({:.2},{:.2}) bounds=({:.2},{:.2},{:.2},{:.2}) threshold={}", + key, + position.x, + position.y, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + tab_drag.threshold + ); + } + } + + if is_lifted(&event) { + state.unfocus(); + } + if let Some(on_activate) = self.on_activate.as_ref() { - if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerLifted { .. }) = event - { + if is_pressed(event) { + state.pressed_item = Some(Item::Tab(key)); + } else if is_lifted(&event) && self.button_is_pressed(state, key) { shell.publish(on_activate(key)); - return event::Status::Captured; + state.set_focused(); + state.focused_item = Item::Tab(key); + state.pressed_item = None; + shell.capture_event(); + return; } } // Present a context menu on a right click event. - if self.context_menu.is_some() { - if let Some(on_context) = self.on_context.as_ref() { - if right_button_released(&event) - || (touch_lifted(&event) && fingers_pressed == 2) - { - state.show_context = Some(key); - state.context_cursor = - cursor_position.position().unwrap_or_default(); - state.focused = true; - state.focused_item = Item::Tab(key); + if self.context_menu.is_some() + && let Some(on_context) = self.on_context.as_ref() + && (right_button_released(&event) + || (touch_lifted(&event) && fingers_pressed == 2)) + { + state.show_context = Some(key); + state.context_cursor = cursor_position.position().unwrap_or_default(); - let menu_state = - tree.children[0].state.downcast_mut::(); - menu_state.open = true; - menu_state.view_cursor = cursor_position; + state.menu_state.inner.with_data_mut(|data| { + data.open = true; + data.view_cursor = cursor_position; + }); - shell.publish(on_context(key)); - return event::Status::Captured; - } - } + shell.publish(on_context(key)); + shell.capture_event(); + return; } if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) = @@ -949,142 +1413,194 @@ where state.middle_clicked = Some(Item::Tab(key)); if let Some(on_middle_press) = self.on_middle_press.as_ref() { shell.publish(on_middle_press(key)); - return event::Status::Captured; + shell.capture_event(); + return; } } } break; + } else if state.hovered == Item::Tab(key) { + state.hovered = Item::None; + self.update_entity_paragraph(state, key); } } - if self.scrollable_focus { - if let Some(on_activate) = self.on_activate.as_ref() { - if let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event { - let current = Instant::now(); + if self.scrollable_focus + && let Some(on_activate) = self.on_activate.as_ref() + && let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event + { + let current = Instant::now(); - // Permit successive scroll wheel events only after a given delay. - if state.wheel_timestamp.map_or(true, |previous| { - current.duration_since(previous) > Duration::from_millis(250) - }) { - state.wheel_timestamp = Some(current); + // Permit successive scroll wheel events only after a given delay. + if state.wheel_timestamp.is_none_or(|previous| { + current.duration_since(previous) > Duration::from_millis(250) + }) { + state.wheel_timestamp = Some(current); - match delta { - ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } => { - let mut activate_key = None; + match delta { + ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } => { + let mut activate_key = None; - if y < 0.0 { - let mut prev_key = Entity::null(); + if *y < 0.0 { + let mut prev_key = Entity::null(); - for key in self.model.order.iter().copied() { - if self.model.is_active(key) && !prev_key.is_null() { - activate_key = Some(prev_key); - } + for key in self.model.order.iter().copied() { + if self.model.is_active(key) && !prev_key.is_null() { + activate_key = Some(prev_key); + } + if self.model.is_enabled(key) { + prev_key = key; + } + } + } else if *y > 0.0 { + let mut buttons = self.model.order.iter().copied(); + while let Some(key) = buttons.next() { + if self.model.is_active(key) { + for key in buttons { if self.model.is_enabled(key) { - prev_key = key; - } - } - } else if y > 0.0 { - let mut buttons = self.model.order.iter().copied(); - while let Some(key) = buttons.next() { - if self.model.is_active(key) { - for key in buttons { - if self.model.is_enabled(key) { - activate_key = Some(key); - break; - } - } + activate_key = Some(key); break; } } - } - - if let Some(key) = activate_key { - shell.publish(on_activate(key)); - return event::Status::Captured; + break; } } } + + if let Some(key) = activate_key { + shell.publish(on_activate(key)); + state.set_focused(); + state.focused_item = Item::Tab(key); + shell.capture_event(); + return; + } } } } } + } else { + if let Item::Tab(_key) = std::mem::replace(&mut state.hovered, Item::None) { + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } + } + if state.is_focused() { + // Unfocus on clicks outside of the boundaries of the segmented button. + if is_pressed(&event) { + state.unfocus(); + state.pressed_item = None; + return; + } + } else if is_lifted(&event) { + state.pressed_item = None; + } } - if state.focused { + if let (Some(tab_drag), Some(candidate)) = + (self.tab_drag.as_ref(), state.tab_drag_candidate) + && let Event::Mouse(mouse::Event::CursorMoved { .. }) = event + && let Some(position) = cursor_position.position() + && position.distance(candidate.origin) >= tab_drag.threshold + && let Some(candidate) = state.tab_drag_candidate.take() + { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "tab drag threshold met entity={:?} distance={:.2} threshold={}", + candidate.entity, + position.distance(candidate.origin), + tab_drag.threshold + ); + if self.start_tab_drag( + state, + candidate.entity, + candidate.bounds, + position, + clipboard, + ) { + shell.capture_event(); + return; + } + } + + if matches!( + event, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + ) { + state.tab_drag_candidate = None; + } + + if state.is_focused() { if let Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Tab), modifiers, .. }) = event { - return if modifiers.shift() { - self.focus_previous(state) - } else { - self.focus_next(state) + state.focused_visible = true; + return if *modifiers == keyboard::Modifiers::SHIFT { + self.focus_previous(state, shell); + } else if modifiers.is_empty() { + self.focus_next(state, shell); }; } - if let Some(on_activate) = self.on_activate.as_ref() { - if let Event::Keyboard(keyboard::Event::KeyReleased { + if let Some(on_activate) = self.on_activate.as_ref() + && let Event::Keyboard(keyboard::Event::KeyReleased { key: keyboard::Key::Named(keyboard::key::Named::Enter), .. }) = event - { - match state.focused_item { - Item::Tab(entity) => { - shell.publish(on_activate(entity)); - } - - Item::PrevButton => { - if self.prev_tab_sensitive(state) { - state.buttons_offset -= 1; - - // If the change would cause it to be insensitive, focus the first tab. - if !self.prev_tab_sensitive(state) { - if let Some(first) = self.first_tab(state) { - state.focused_item = Item::Tab(first); - } - } - } - } - - Item::NextButton => { - if self.next_tab_sensitive(state) { - state.buttons_offset += 1; - - // If the change would cause it to be insensitive, focus the last tab. - if !self.next_tab_sensitive(state) { - if let Some(last) = self.last_tab(state) { - state.focused_item = Item::Tab(last); - } - } - } - } - - Item::None | Item::Set => (), + { + match state.focused_item { + Item::Tab(entity) => { + shell.publish(on_activate(entity)); } - return event::Status::Captured; + Item::PrevButton => { + if self.prev_tab_sensitive(state) { + state.buttons_offset -= 1; + + // If the change would cause it to be insensitive, focus the first tab. + if !self.prev_tab_sensitive(state) + && let Some(first) = self.first_tab(state) + { + state.focused_item = Item::Tab(first); + } + } + } + + Item::NextButton => { + if self.next_tab_sensitive(state) { + state.buttons_offset += 1; + + // If the change would cause it to be insensitive, focus the last tab. + if !self.next_tab_sensitive(state) + && let Some(last) = self.last_tab(state) + { + state.focused_item = Item::Tab(last); + } + } + } + + Item::None | Item::Set => (), } + + shell.capture_event(); } } - - event::Status::Ignored } fn operate( - &self, + &mut self, tree: &mut Tree, - _layout: Layout<'_>, + layout: Layout<'_>, _renderer: &Renderer, - operation: &mut dyn iced_core::widget::Operation< - iced_core::widget::OperationOutputWrapper, - >, + operation: &mut dyn iced_core::widget::Operation<()>, ) { let state = tree.state.downcast_mut::(); - operation.focusable(state, Some(&self.id.0)); + operation.focusable(Some(&self.id.0), layout.bounds(), state); + operation.custom(Some(&self.id.0), layout.bounds(), state); if let Item::Set = state.focused_item { if self.prev_tab_sensitive(state) { @@ -1127,7 +1643,7 @@ where } } - iced_core::mouse::Interaction::Idle + iced_core::mouse::Interaction::default() } #[allow(clippy::too_many_lines)] @@ -1145,34 +1661,11 @@ where let appearance = Self::variant_appearance(theme, &self.style); let bounds: Rectangle = layout.bounds(); let button_amount = self.model.items.len(); - - // Modifies alpha color when `on_activate` is unset. - let apply_alpha = |mut c: Color| { - if self.on_activate.is_none() { - c.a /= 2.0; - } - - c - }; - - // Maps `apply_alpha` to background color. - let bg_with_alpha = |mut b| { - match &mut b { - Background::Color(c) => { - *c = apply_alpha(*c); - } - - Background::Gradient(g) => { - let Gradient::Linear(mut l) = g; - for c in &mut l.stops { - let Some(stop) = c else { - continue; - }; - stop.color = apply_alpha(stop.color); - } - } - } - b + let show_drop_hint = state.dragging_tab.is_some(); + let drop_hint = if show_drop_hint { + state.drop_hint + } else { + None }; // Draw the background, if a background was defined. @@ -1180,13 +1673,11 @@ where renderer.fill_quad( renderer::Quad { bounds, - border: Border { - radius: appearance.border_radius, - ..Border::default() - }, + border: appearance.border, shadow: Shadow::default(), + snap: true, }, - bg_with_alpha(background), + background, ); } @@ -1197,7 +1688,7 @@ where // Previous tab button let mut background_appearance = if self.on_activate.is_some() && Item::PrevButton == state.focused_item { - Some(appearance.focus) + Some(appearance.active) } else if self.on_activate.is_some() && Item::PrevButton == state.hovered { Some(appearance.hover) } else { @@ -1213,10 +1704,11 @@ where ..Default::default() }, shadow: Shadow::default(), + snap: true, }, background_appearance .background - .map_or(Background::Color(Color::TRANSPARENT), bg_with_alpha), + .unwrap_or(Background::Color(Color::TRANSPARENT)), ); } @@ -1226,13 +1718,11 @@ where style, cursor, viewport, - apply_alpha(if state.buttons_offset == 0 { + if state.buttons_offset == 0 { appearance.inactive.text_color - } else if let Item::PrevButton = state.focused_item { - appearance.focus.text_color } else { appearance.active.text_color - }), + }, Rectangle { x: tab_bounds.x + 8.0, y: tab_bounds.y + f32::from(self.button_height) / 4.0, @@ -1247,7 +1737,7 @@ where // Next tab button background_appearance = if self.on_activate.is_some() && Item::NextButton == state.focused_item { - Some(appearance.focus) + Some(appearance.active) } else if self.on_activate.is_some() && Item::NextButton == state.hovered { Some(appearance.hover) } else { @@ -1263,6 +1753,7 @@ where ..Default::default() }, shadow: Shadow::default(), + snap: true, }, background_appearance .background @@ -1276,13 +1767,13 @@ where style, cursor, viewport, - apply_alpha(if self.next_tab_sensitive(state) { + if self.next_tab_sensitive(state) { appearance.active.text_color } else if let Item::NextButton = state.focused_item { - appearance.focus.text_color + appearance.active.text_color } else { appearance.inactive.text_color - }), + }, Rectangle { x: tab_bounds.x + 8.0, y: tab_bounds.y + f32::from(self.button_height) / 4.0, @@ -1293,8 +1784,19 @@ where ); } + let rad_0 = THEME.lock().unwrap().cosmic().corner_radii.radius_0; + + let divider_background = Background::Color( + crate::theme::active() + .cosmic() + .primary_component_divider() + .into(), + ); + // Draw each of the items in the widget. let mut nth = 0; + let drop_hint_marker = drop_hint; + let show_drop_hint_marker = show_drop_hint; self.variant_bounds(state, bounds).for_each(move |item| { let (key, mut bounds) = match item { // Draw a button @@ -1307,6 +1809,7 @@ where bounds, border: Border::default(), shadow: Shadow::default(), + snap: true, }, { let theme = crate::theme::active(); @@ -1322,16 +1825,46 @@ where } }; + let original_bounds = bounds; let center_y = bounds.center_y(); + if show_drop_hint_marker + && matches!( + drop_hint_marker, + Some(DropHint { + entity, + side: DropSide::Before + }) if entity == key + ) + { + draw_drop_indicator( + renderer, + original_bounds, + DropSide::Before, + Self::VERTICAL, + appearance.active.text_color, + ); + } + + let menu_open = || { + state.show_context == Some(key) + && !tree.children.is_empty() + && tree.children[0] + .state + .downcast_ref::() + .inner + .with_data(|data| data.open) + }; + let key_is_active = self.model.is_active(key); + let key_is_focused = state.focused_visible && self.button_is_focused(state, key); let key_is_hovered = self.button_is_hovered(state, key); - let status_appearance = if self.button_is_focused(state, key) { - appearance.focus + let status_appearance = if self.button_is_pressed(state, key) { + appearance.pressed + } else if key_is_hovered || menu_open() { + appearance.hover } else if key_is_active { appearance.active - } else if key_is_hovered { - appearance.hover } else { appearance.inactive }; @@ -1344,58 +1877,117 @@ where status_appearance.middle }; - // Render the background of the button. - if status_appearance.background.is_some() { + // Draw the active hint on tabs + if appearance.active_width > 0.0 { + let active_width = if key_is_active { + appearance.active_width + } else { + 1.0 + }; + renderer.fill_quad( renderer::Quad { - bounds, - border: Border { - radius: button_appearance.border_radius, - ..Default::default() + bounds: if Self::VERTICAL { + Rectangle { + x: bounds.x + bounds.width - active_width, + width: active_width, + ..bounds + } + } else { + Rectangle { + y: bounds.y + bounds.height - active_width, + height: active_width, + ..bounds + } }, - shadow: Shadow::default(), - }, - status_appearance - .background - .map_or(Background::Color(Color::TRANSPARENT), bg_with_alpha), - ); - } - - // Draw the bottom border defined for this button. - if let Some((width, background)) = button_appearance.border_bottom { - let mut bounds = bounds; - bounds.y = bounds.y + bounds.height - width; - bounds.height = width; - - let rad_0 = THEME.lock().unwrap().cosmic().corner_radii.radius_0; - renderer.fill_quad( - renderer::Quad { - bounds, border: Border { radius: rad_0.into(), ..Default::default() }, shadow: Shadow::default(), + snap: true, }, - bg_with_alpha(background.into()), + appearance.active.text_color, ); } - let original_bounds = bounds; - bounds.x += f32::from(self.button_padding[0]); bounds.width -= f32::from(self.button_padding[0]) - f32::from(self.button_padding[2]); + let mut indent_padding = 0.0; // Adjust bounds by indent - if let Some(indent) = self.model.indent(key) { + if let Some(indent) = self.model.indent(key) + && indent > 0 + { let adjustment = f32::from(indent) * f32::from(self.indent_spacing); bounds.x += adjustment; bounds.width -= adjustment; + + // Draw indent line + if let crate::theme::SegmentedButton::FileNav = self.style + && indent > 1 + { + indent_padding = 7.0; + + for level in 1..indent { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: (level as f32) + .mul_add(-(self.indent_spacing as f32), bounds.x) + + indent_padding, + width: 1.0, + ..bounds + }, + border: Border { + radius: rad_0.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }, + divider_background, + ); + } + + indent_padding += 4.0; + } + } + + // Render the background of the button. + if key_is_focused || status_appearance.background.is_some() { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x - f32::from(self.button_padding[0]) + indent_padding, + width: bounds.width + f32::from(self.button_padding[0]) + - f32::from(self.button_padding[2]) + - indent_padding, + ..bounds + }, + border: if key_is_focused { + Border { + width: 1.0, + color: appearance.active.text_color, + radius: button_appearance.border.radius, + } + } else { + button_appearance.border + }, + shadow: Shadow::default(), + snap: true, + }, + status_appearance + .background + .unwrap_or(Background::Color(Color::TRANSPARENT)), + ); } // Align contents of the button to the requested `button_alignment`. { - let actual_width = state.internal_layout[nth].1.width; + // Avoid shifting content outside the left edge when the measured content is + // wider than the available button bounds (for example, non-ellipsized text). + let actual_width = state.internal_layout[nth].1.width.min(bounds.width); let offset = match self.button_alignment { Alignment::Start => None, @@ -1422,7 +2014,7 @@ where style, cursor, viewport, - apply_alpha(status_appearance.text_color), + status_appearance.text_color, Rectangle { width, height: width, @@ -1434,39 +2026,35 @@ where bounds.x += offset; } else { // Draw the selection indicator if widget is a segmented selection, and the item is selected. - if key_is_active { - if let crate::theme::SegmentedButton::Control = self.style { - let mut image_bounds = bounds; - image_bounds.y = center_y - 16.0 / 2.0; + if key_is_active && let crate::theme::SegmentedButton::Control = self.style { + let mut image_bounds = bounds; + image_bounds.y = center_y - 8.0; - draw_icon::( - renderer, - theme, - style, - cursor, - viewport, - apply_alpha(status_appearance.text_color), - Rectangle { - width: 16.0, - height: 16.0, - ..image_bounds - }, - crate::widget::icon( - match crate::widget::common::object_select().data() { - crate::iced_core::svg::Data::Bytes(bytes) => { - crate::widget::icon::from_svg_bytes(bytes.as_ref()) - } - crate::iced_core::svg::Data::Path(path) => { - crate::widget::icon::from_path(path.clone()) - } - }, - ), - ); + draw_icon::( + renderer, + theme, + style, + cursor, + viewport, + status_appearance.text_color, + Rectangle { + width: 16.0, + height: 16.0, + ..image_bounds + }, + crate::widget::icon(match crate::widget::common::object_select().data() { + iced_core::svg::Data::Bytes(bytes) => { + crate::widget::icon::from_svg_bytes(bytes.as_ref()).symbolic(true) + } + iced_core::svg::Data::Path(path) => { + crate::widget::icon::from_path(path.clone()) + } + }), + ); - let offset = 16.0 + f32::from(self.button_spacing); + let offset = 16.0 + f32::from(self.button_spacing); - bounds.x += offset; - } + bounds.x += offset; } } @@ -1490,15 +2078,20 @@ where bounds.y = center_y; if self.model.text(key).is_some_and(|text| !text.is_empty()) { + // FIXME why has this behavior changed? Does the center alignment not work with infinite bounds now? + bounds.y -= state.paragraphs[key].min_height() / 2.; + // Draw the text for this segmented button or tab. renderer.fill_paragraph( - &state.paragraphs[key], + state.paragraphs[key].raw(), bounds.position(), - apply_alpha(status_appearance.text_color), + status_appearance.text_color, Rectangle { x: bounds.x, width: bounds.width, - ..original_bounds + height: original_bounds.height, + y: bounds.y, + // ..original_bounds, }, ); } @@ -1513,12 +2106,30 @@ where style, cursor, viewport, - apply_alpha(status_appearance.text_color), + status_appearance.text_color, close_button_bounds, self.close_icon.clone(), ); } + if show_drop_hint_marker { + if matches!( + drop_hint_marker, + Some(DropHint { + entity, + side: DropSide::After + }) if entity == key + ) { + draw_drop_indicator( + renderer, + original_bounds, + DropSide::After, + Self::VERTICAL, + appearance.active.text_color, + ); + } + } + nth += 1; }); } @@ -1526,40 +2137,42 @@ where fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: iced_core::Layout<'_>, + layout: iced_core::Layout<'b>, _renderer: &Renderer, + _viewport: &iced_core::Rectangle, + translation: Vector, ) -> Option> { - let state = tree.state.downcast_ref::(); + let state = tree.state.downcast_mut::(); + let menu_state = state.menu_state.clone(); - let Some(entity) = state.show_context else { - return None; - }; + let entity = state.show_context?; - let bounds = self - .variant_bounds(state, layout.bounds()) - .find_map(|item| match item { - ItemBounds::Button(e, bounds) if e == entity => Some(bounds), - _ => None, - }); - let Some(mut bounds) = bounds else { - return None; - }; + let mut bounds = + self.variant_bounds(state, layout.bounds()) + .find_map(|item| match item { + ItemBounds::Button(e, bounds) if e == entity => Some(bounds), + _ => None, + })?; - let Some(context_menu) = self.context_menu.as_mut() else { - return None; - }; + let context_menu = self.context_menu.as_mut()?; - if !tree.children[0].state.downcast_ref::().open { + if !menu_state.inner.with_data(|data| data.open) { + // If the menu is not open, we don't need to show it. + // We also clear the context entity and update the text + // cache so that the item is not bold when the context menu is closed + state.show_context = None; + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } return None; } - bounds.x = state.context_cursor.x; bounds.y = state.context_cursor.y; Some( crate::widget::menu::Menu { - tree: &mut tree.children[0], - menu_roots: context_menu, + tree: menu_state, + menu_roots: std::borrow::Cow::Owned(context_menu.clone()), bounds_expand: 16, menu_overlays_parent: true, close_condition: CloseCondition { @@ -1570,11 +2183,16 @@ where item_width: ItemWidth::Uniform(240), item_height: ItemHeight::Dynamic(40), bar_bounds: bounds, - main_offset: -(bounds.height as i32), + main_offset: -bounds.height as i32, cross_offset: 0, root_bounds_list: vec![bounds], path_highlight: Some(PathHighlight::MenuActive), - style: &crate::theme::menu_bar::MenuBarStyle::Default, + style: std::borrow::Cow::Borrowed(&crate::theme::menu_bar::MenuBarStyle::Default), + position: Point::new(translation.x, translation.y), + is_overlay: true, + window_id: window::Id::NONE, + depth: 0, + on_surface_action: None, } .overlay(), ) @@ -1582,27 +2200,98 @@ where fn drag_destinations( &self, - _state: &Tree, + tree: &Tree, layout: Layout<'_>, _renderer: &Renderer, dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { - let bounds = layout.bounds(); - + let local_state = tree.state.downcast_ref::(); let my_id = self.get_drag_id(); - let dnd_rect = DndDestinationRectangle { - id: my_id, - rectangle: dnd::Rectangle { - x: f64::from(bounds.x), - y: f64::from(bounds.y), - width: f64::from(bounds.width), - height: f64::from(bounds.height), - }, - mime_types: self.mimes.clone().into_iter().map(Cow::Owned).collect(), - actions: DndAction::Copy | DndAction::Move, - preferred: DndAction::Move, - }; - dnd_rectangles.push(dnd_rect); + let mut pushed = false; + + for item in self.variant_bounds(local_state, layout.bounds()) { + if let ItemBounds::Button(_entity, rect) = item { + pushed = true; + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "register drag destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}", + my_id, + rect.x, + rect.y, + rect.width, + rect.height, + self.mimes + ); + dnd_rectangles.push(DndDestinationRectangle { + id: my_id, + rectangle: dnd::Rectangle { + x: f64::from(rect.x), + y: f64::from(rect.y), + width: f64::from(rect.width), + height: f64::from(rect.height), + }, + mime_types: self.mimes.clone().into_iter().map(Cow::Owned).collect(), + actions: DndAction::Copy | DndAction::Move, + preferred: DndAction::Move, + }); + } + } + + if let Some(mime) = self.tab_drag.as_ref().map(|d| &d.mime) { + for item in self.variant_bounds(local_state, layout.bounds()) { + if let ItemBounds::Button(_entity, rect) = item { + pushed = true; + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "register drag destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}", + my_id, + rect.x, + rect.y, + rect.width, + rect.height, + mime + ); + dnd_rectangles.push(DndDestinationRectangle { + id: my_id, + rectangle: dnd::Rectangle { + x: f64::from(rect.x), + y: f64::from(rect.y), + width: f64::from(rect.width), + height: f64::from(rect.height), + }, + mime_types: vec![Cow::Owned(mime.clone())], + actions: DndAction::Copy | DndAction::Move, + preferred: DndAction::Move, + }); + } + } + } + + if !pushed { + let bounds = layout.bounds(); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "register drag destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}", + my_id, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + self.mimes + ); + dnd_rectangles.push(DndDestinationRectangle { + id: my_id, + rectangle: dnd::Rectangle { + x: f64::from(bounds.x), + y: f64::from(bounds.y), + width: f64::from(bounds.width), + height: f64::from(bounds.height), + }, + mime_types: self.mimes.clone().into_iter().map(Cow::Owned).collect(), + actions: DndAction::Copy | DndAction::Move, + preferred: DndAction::Move, + }); + } } } @@ -1624,16 +2313,74 @@ where } } +struct TabDragSource { + mime: String, + threshold: f32, + _marker: PhantomData, +} + +impl TabDragSource { + fn new(mime: String) -> Self { + Self { + mime, + threshold: 8.0, + _marker: PhantomData, + } + } +} + +struct SimpleDragData { + mime: String, + bytes: Vec, +} + +impl SimpleDragData { + fn new(mime: String, bytes: Vec) -> Self { + Self { mime, bytes } + } +} + +impl iced::clipboard::mime::AsMimeTypes for SimpleDragData { + fn available(&self) -> Cow<'static, [String]> { + Cow::Owned(vec![self.mime.clone()]) + } + + fn as_bytes(&self, mime_type: &str) -> Option> { + if mime_type == self.mime { + Some(Cow::Owned(self.bytes.clone())) + } else { + None + } + } +} + +#[derive(Clone, Copy)] +struct TabDragCandidate { + entity: Entity, + bounds: Rectangle, + origin: Point, +} + +#[derive(Debug, Clone, Copy)] +struct Focus { + updated_at: Instant, + now: Instant, +} + /// State that is maintained by each individual widget. pub struct LocalState { + /// Menu state + pub(crate) menu_state: MenuBarState, /// Defines how many buttons to show at a time. pub(super) buttons_visible: usize, /// Button visibility offset, when collapsed. pub(super) buttons_offset: usize, /// Whether buttons need to be collapsed to preserve minimum width pub(super) collapsed: bool, + /// Visibility of focus state + focused_visible: bool, /// If the widget is focused or not. - focused: bool, + focused: Option, /// The key inside the widget that is currently focused. focused_item: Item, /// The ID of the button that is being hovered. Defaults to null. @@ -1645,7 +2392,7 @@ pub struct LocalState { /// Dimensions of internal buttons when shrinking pub(super) internal_layout: Vec<(Size, Size)>, /// The paragraphs for each text. - paragraphs: SecondaryMap, + paragraphs: SecondaryMap, /// Used to detect changes in text. text_hashes: SecondaryMap, /// Location of cursor when context menu was opened. @@ -1656,8 +2403,18 @@ pub struct LocalState { wheel_timestamp: Option, /// Dnd state pub dnd_state: crate::widget::dnd_destination::State>, + /// Dnd state + pub offer_mimes: Vec, /// Tracks multi-touch events fingers_pressed: HashSet, + /// The currently pressed item + pressed_item: Option, + /// Pending tab drag candidate data + tab_drag_candidate: Option, + /// Currently dragging tab entity + dragging_tab: Option, + /// Current drop hint for drag-and-drop indicator + drop_hint: Option, } #[derive(Debug, Default, PartialEq)] @@ -1670,19 +2427,172 @@ enum Item { Tab(Entity), } +impl LocalState { + fn set_focused(&mut self) { + let now = Instant::now(); + LAST_FOCUS_UPDATE.with(|x| x.set(now)); + + self.focused = Some(Focus { + updated_at: now, + now, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::widget::segmented_button::{self, Appearance as SegAppearance}; + use iced::Size; + use slotmap::SecondaryMap; + use std::collections::HashSet; + + #[derive(Clone, Debug)] + enum TestMessage {} + + struct TestVariant; + + impl SegmentedVariant + for SegmentedButton<'_, TestVariant, SelectionMode, Message> + where + Model: Selectable, + SelectionMode: Default, + { + const VERTICAL: bool = false; + + fn variant_appearance( + _theme: &crate::Theme, + _style: &crate::theme::SegmentedButton, + ) -> SegAppearance { + SegAppearance::default() + } + + fn variant_bounds<'b>( + &'b self, + _state: &'b LocalState, + bounds: Rectangle, + ) -> Box + 'b> { + let len = self.model.order.len(); + if len == 0 { + return Box::new(std::iter::empty()); + } + let width = bounds.width / len as f32; + Box::new( + self.model + .order + .iter() + .copied() + .enumerate() + .map(move |(idx, entity)| { + let rect = Rectangle { + x: bounds.x + (idx as f32) * width, + y: bounds.y, + width, + height: bounds.height, + }; + ItemBounds::Button(entity, rect) + }), + ) + } + + fn variant_layout( + &self, + _state: &mut LocalState, + _renderer: &crate::Renderer, + _limits: &layout::Limits, + ) -> Size { + Size::ZERO + } + } + + fn sample_model() -> ( + segmented_button::SingleSelectModel, + Vec, + ) { + let mut entities = Vec::new(); + let model = segmented_button::Model::builder() + .insert(|b| b.text("One").with_id(|id| entities.push(id))) + .insert(|b| b.text("Two").with_id(|id| entities.push(id))) + .insert(|b| b.text("Three").with_id(|id| entities.push(id))) + .build(); + (model, entities) + } + + fn test_state(dragging: segmented_button::Entity, len: usize) -> LocalState { + let mut state = LocalState { + menu_state: MenuBarState::default(), + paragraphs: SecondaryMap::new(), + text_hashes: SecondaryMap::new(), + buttons_visible: 0, + buttons_offset: 0, + collapsed: false, + focused: None, + focused_item: Item::default(), + focused_visible: false, + hovered: Item::default(), + known_length: 0, + middle_clicked: None, + internal_layout: Vec::new(), + context_cursor: Point::ORIGIN, + show_context: None, + wheel_timestamp: None, + dnd_state: crate::widget::dnd_destination::State::>::new(), + fingers_pressed: HashSet::new(), + pressed_item: None, + tab_drag_candidate: None, + dragging_tab: Some(dragging), + drop_hint: None, + offer_mimes: Vec::new(), + }; + state.buttons_visible = len; + state.known_length = len; + state + } + + #[test] + fn drop_hint_reports_before_and_after() { + let (model, ids) = sample_model(); + let button = + SegmentedButton::::new( + &model, + ); + let state = test_state(ids[0], model.order.len()); + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 300.0, + height: 30.0, + }; + let before = button + .drop_hint_for_position(&state, bounds, Point::new(10.0, 15.0)) + .expect("hint"); + assert_eq!(before.entity, ids[0]); + assert!(matches!(before.side, DropSide::Before)); + + let after = button + .drop_hint_for_position(&state, bounds, Point::new(290.0, 15.0)) + .expect("hint"); + assert_eq!(after.entity, ids[2]); + assert!(matches!(after.side, DropSide::After)); + } +} + impl operation::Focusable for LocalState { fn is_focused(&self) -> bool { self.focused + .is_some_and(|f| f.updated_at == LAST_FOCUS_UPDATE.with(|f| f.get())) } fn focus(&mut self) { - self.focused = true; + self.set_focused(); + self.focused_visible = true; self.focused_item = Item::Set; } fn unfocus(&mut self) { - self.focused = false; + self.focused = None; self.focused_item = Item::None; + self.focused_visible = false; self.show_context = None; } } @@ -1701,6 +2611,7 @@ impl Id { /// /// This function produces a different [`Id`] every time it is called. #[must_use] + #[inline] pub fn unique() -> Self { Self(widget::Id::unique()) } @@ -1763,7 +2674,7 @@ fn draw_icon( }); Widget::::draw( - Element::::from(icon.clone()).as_widget(), + Element::::from(icon).as_widget(), &Tree::empty(), renderer, theme, @@ -1778,6 +2689,54 @@ fn draw_icon( ); } +fn draw_drop_indicator( + renderer: &mut Renderer, + bounds: Rectangle, + side: DropSide, + vertical: bool, + color: Color, +) { + let thickness = 4.0; + let quad_bounds = if vertical { + let y = match side { + DropSide::Before => bounds.y - thickness / 2.0, + DropSide::After => bounds.y + bounds.height - thickness / 2.0, + }; + + Rectangle { + x: bounds.x, + y, + width: bounds.width, + height: thickness, + } + } else { + let x = match side { + DropSide::Before => bounds.x - thickness / 2.0, + DropSide::After => bounds.x + bounds.width - thickness / 2.0, + }; + + Rectangle { + x, + y: bounds.y, + width: thickness, + height: bounds.height, + } + }; + + renderer.fill_quad( + renderer::Quad { + bounds: quad_bounds, + border: Border { + radius: 2.0.into(), + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }, + Background::Color(color), + ); +} + fn left_button_released(event: &Event) -> bool { matches!( event, @@ -1792,6 +2751,22 @@ fn right_button_released(event: &Event) -> bool { ) } +fn is_pressed(event: &Event) -> bool { + matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) + ) +} + +fn is_lifted(event: &Event) -> bool { + matches!( + event, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left,)) + | Event::Touch(touch::Event::FingerLifted { .. }) + ) +} + fn touch_lifted(event: &Event) -> bool { matches!(event, Event::Touch(touch::Event::FingerLifted { .. })) } diff --git a/src/widget/segmented_control.rs b/src/widget/segmented_control.rs index 6466f8b3..046956c7 100644 --- a/src/widget/segmented_control.rs +++ b/src/widget/segmented_control.rs @@ -16,13 +16,12 @@ use super::segmented_button::{ /// For details on the model, see the [`segmented_button`] module for more details. pub fn horizontal( model: &Model, -) -> HorizontalSegmentedButton +) -> HorizontalSegmentedButton<'_, SelectionMode, Message> where Model: Selectable, { - let theme = crate::theme::active(); - let space_s = theme.cosmic().space_s(); - let space_xxs = theme.cosmic().space_xxs(); + let space_s = crate::theme::spacing().space_s; + let space_xxs = crate::theme::spacing().space_xxs; segmented_button::horizontal(model) .button_alignment(iced::Alignment::Center) @@ -31,7 +30,6 @@ where .button_padding([space_s, 0, space_s, 0]) .button_spacing(space_xxs) .style(crate::theme::SegmentedButton::Control) - .font_active(Some(crate::font::semibold())) } /// A selection of multiple choices appearing as a conjoined button. @@ -41,14 +39,13 @@ where /// For details on the model, see the [`segmented_button`] module for more details. pub fn vertical( model: &Model, -) -> VerticalSegmentedButton +) -> VerticalSegmentedButton<'_, SelectionMode, Message> where Model: Selectable, SelectionMode: Default, { - let theme = crate::theme::active(); - let space_s = theme.cosmic().space_s(); - let space_xxs = theme.cosmic().space_xxs(); + let space_s = crate::theme::spacing().space_s; + let space_xxs = crate::theme::spacing().space_xxs; segmented_button::vertical(model) .button_alignment(iced::Alignment::Center) @@ -57,5 +54,4 @@ where .button_padding([space_s, 0, space_s, 0]) .button_spacing(space_xxs) .style(crate::theme::SegmentedButton::Control) - .font_active(Some(crate::font::semibold())) } diff --git a/src/widget/settings/item.rs b/src/widget/settings/item.rs index b21df312..5abb464c 100644 --- a/src/widget/settings/item.rs +++ b/src/widget/settings/item.rs @@ -4,12 +4,12 @@ use std::borrow::Cow; use crate::{ - theme, - widget::{column, container, flex_row, horizontal_space, row, text, FlexRow, Row}, - Element, + Element, Theme, theme, + widget::{FlexRow, Row, column, container, flex_row, list, row, text}, }; use derive_setters::Setters; -use iced_core::{text::Wrap, Length}; +use iced_core::{Length, text::Wrapping}; +use iced_widget::space; use taffy::AlignContent; /// A settings item aligned in a row @@ -18,25 +18,30 @@ use taffy::AlignContent; pub fn item<'a, Message: 'static>( title: impl Into> + 'a, widget: impl Into> + 'a, -) -> Row<'a, Message> { - item_row(vec![ - text(title).wrap(Wrap::Word).into(), - horizontal_space(iced::Length::Fill).into(), - widget.into(), - ]) +) -> Row<'a, Message, Theme> { + #[inline(never)] + fn inner<'a, Message: 'static>( + title: Cow<'a, str>, + widget: Element<'a, Message>, + ) -> Row<'a, Message, Theme> { + item_row(vec![ + text(title).wrapping(Wrapping::Word).into(), + space::horizontal().into(), + widget, + ]) + } + + inner(title.into(), widget.into()) } /// A settings item aligned in a row #[must_use] #[allow(clippy::module_name_repetitions)] -pub fn item_row(children: Vec>) -> Row { - let cosmic_theme::Spacing { - space_s, space_xs, .. - } = theme::THEME.lock().unwrap().cosmic().spacing; +pub fn item_row(children: Vec>) -> Row { row::with_children(children) - .spacing(space_xs) - .align_items(iced::Alignment::Center) - .padding([0, space_s]) + .spacing(theme::spacing().space_xs) + .align_y(iced::Alignment::Center) + .width(Length::Fill) } /// A settings item aligned in a flex row @@ -45,21 +50,29 @@ pub fn flex_item<'a, Message: 'static>( title: impl Into> + 'a, widget: impl Into> + 'a, ) -> FlexRow<'a, Message> { - flex_item_row(vec![ - text(title).wrap(Wrap::Word).width(Length::Fill).into(), - container(widget).into(), - ]) + #[inline(never)] + fn inner<'a, Message: 'static>( + title: Cow<'a, str>, + widget: Element<'a, Message>, + ) -> FlexRow<'a, Message> { + flex_item_row(vec![ + text(title) + .wrapping(Wrapping::Word) + .width(Length::Fill) + .into(), + container(widget).width(Length::Shrink).into(), + ]) + .width(Length::Fill) + } + + inner(title.into(), widget.into()) } /// A settings item aligned in a flex row #[allow(clippy::module_name_repetitions)] pub fn flex_item_row(children: Vec>) -> FlexRow { - let cosmic_theme::Spacing { - space_s, space_xs, .. - } = theme::THEME.lock().unwrap().cosmic().spacing; flex_row(children) - .padding([0, space_s]) - .spacing(space_xs) + .spacing(theme::spacing().space_xs) .min_item_width(200.0) .justify_items(iced::Alignment::Center) .justify_content(AlignContent::SpaceBetween) @@ -90,45 +103,120 @@ pub struct Item<'a, Message> { icon: Option>, } -impl<'a, Message: 'static> Item<'a, Message> { +impl<'a, Message: Clone + 'static> Item<'a, Message> { /// Assigns a control to the item. - pub fn control(self, widget: impl Into>) -> Row<'a, Message> { - item_row(self.control_(widget)) + pub fn control(self, widget: impl Into>) -> Row<'a, Message, Theme> { + item_row(self.control_(widget.into())) } /// Assigns a control which flexes. pub fn flex_control(self, widget: impl Into>) -> FlexRow<'a, Message> { - flex_item_row(self.control_(widget)) + flex_item_row(self.control_(widget.into())) } - fn control_(self, widget: impl Into>) -> Vec> { - let mut contents = Vec::with_capacity(4); + fn label(self) -> Element<'a, Message> { + if let Some(description) = self.description { + column::with_capacity(2) + .spacing(2) + .push(text::body(self.title).wrapping(Wrapping::Word)) + .push(text::caption(description).wrapping(Wrapping::Word)) + .width(Length::Fill) + .into() + } else { + text(self.title).width(Length::Fill).into() + } + } - if let Some(icon) = self.icon { + #[inline(never)] + fn control_(mut self, widget: Element<'a, Message>) -> Vec> { + let mut contents = Vec::with_capacity(3); + if let Some(icon) = self.icon.take() { contents.push(icon); } - - if let Some(description) = self.description { - let column = column::with_capacity(2) - .spacing(2) - .push(text(self.title).wrap(Wrap::Word)) - .push(text(description).wrap(Wrap::Word).size(10)) - .width(Length::Fill); - - contents.push(column.into()); - } else { - contents.push(text(self.title).width(Length::Fill).into()); - } - - contents.push(widget.into()); + contents.push(self.label()); + contents.push(widget); contents } + fn control_start(self, widget: impl Into>) -> Row<'a, Message, Theme> { + item_row(vec![widget.into(), self.label()]) + } + pub fn toggler( self, is_checked: bool, message: impl Fn(bool) -> Message + 'static, - ) -> Row<'a, Message> { - self.control(crate::widget::toggler(None, is_checked, message)) + ) -> list::ListButton<'a, Message> { + let on_press = message(!is_checked); + list::button( + self.control( + crate::widget::toggler(is_checked) + .width(Length::Shrink) + .on_toggle(message), + ), + ) + .on_press(on_press) + } + + pub fn toggler_maybe( + self, + is_checked: bool, + message: Option Message + 'static>, + ) -> list::ListButton<'a, Message> { + let on_press = message.as_ref().map(|f| f(!is_checked)); + list::button( + self.control( + crate::widget::toggler(is_checked) + .width(Length::Shrink) + .on_toggle_maybe(message), + ), + ) + .on_press_maybe(on_press) + } + + pub fn checkbox( + self, + is_checked: bool, + message: impl Fn(bool) -> Message + 'static, + ) -> list::ListButton<'a, Message> { + let on_press = message(!is_checked); + list::button( + self.control_start( + crate::widget::checkbox(is_checked) + .width(Length::Shrink) + .on_toggle(message), + ), + ) + .on_press(on_press) + } + + pub fn checkbox_maybe( + self, + is_checked: bool, + message: Option Message + 'static>, + ) -> list::ListButton<'a, Message> { + let on_press = message.as_ref().map(|f| f(!is_checked)); + list::button( + self.control_start( + crate::widget::checkbox(is_checked) + .width(Length::Shrink) + .on_toggle_maybe(message), + ), + ) + .on_press_maybe(on_press) + } + + pub fn radio(self, value: V, selected: Option, f: F) -> list::ListButton<'a, Message> + where + V: Eq + Copy, + F: Fn(V) -> Message, + { + let on_press = f(value); + list::button( + self.control_start(crate::widget::radio::Radio::new_no_label( + value, selected, f, + )), + ) + .on_press(on_press) } } diff --git a/src/widget/settings/mod.rs b/src/widget/settings/mod.rs index f4dfbeab..79d81697 100644 --- a/src/widget/settings/mod.rs +++ b/src/widget/settings/mod.rs @@ -5,16 +5,13 @@ pub mod item; pub mod section; pub use self::item::{flex_item, flex_item_row, item, item_row}; -pub use self::section::{section, view_section, Section}; +pub use self::section::{Section, section}; -use crate::widget::{column, Column}; -use crate::{theme, Element}; +use crate::widget::{Column, column}; +use crate::{Element, Theme, theme}; /// A column with a predefined style for creating a settings panel #[must_use] -pub fn view_column(children: Vec>) -> Column { - let spacing = theme::THEME.lock().unwrap().cosmic().spacing; - column::with_children(children) - .spacing(spacing.space_m) - .padding([0, spacing.space_m]) +pub fn view_column(children: Vec>) -> Column { + column::with_children(children).spacing(theme::spacing().space_m) } diff --git a/src/widget/settings/section.rs b/src/widget/settings/section.rs index 3e6c66aa..3dddb1a1 100644 --- a/src/widget/settings/section.rs +++ b/src/widget/settings/section.rs @@ -1,65 +1,79 @@ // Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 -use crate::ext::CollectionWidget; -use crate::widget::{column, text, ListColumn}; use crate::Element; +use crate::widget::list_column::IntoListItem; +use crate::widget::{ListColumn, column, list_column, text}; use std::borrow::Cow; /// A section within a settings view column. -#[deprecated(note = "use `settings::section().title()` instead")] -pub fn view_section<'a, Message: 'static>(title: impl Into>) -> Section<'a, Message> { - Section { - title: title.into(), - children: ListColumn::default(), - } -} - -/// A section within a settings view column. -pub fn section<'a, Message: 'static>() -> Section<'a, Message> { +pub fn section<'a, Message: Clone + 'static>() -> Section<'a, Message> { with_column(ListColumn::default()) } +/// A section with a pre-defined list column of a given capacity. +pub fn with_capacity<'a, Message: Clone + 'static>(capacity: usize) -> Section<'a, Message> { + with_column(list_column::with_capacity(capacity)) +} + /// A section with a pre-defined list column. -pub fn with_column<'a, Message: 'static>( - children: ListColumn<'a, Message>, -) -> Section<'a, Message> { +pub fn with_column( + children: ListColumn<'_, Message>, +) -> Section<'_, Message> { Section { - title: Cow::Borrowed(""), + header: None, children, } } #[must_use] pub struct Section<'a, Message> { - title: Cow<'a, str>, + header: Option>, children: ListColumn<'a, Message>, } -impl<'a, Message: 'static> Section<'a, Message> { - /// Add a child element to the section's list column. - #[allow(clippy::should_implement_trait)] - pub fn add(mut self, item: impl Into>) -> Self { - self.children = self.children.add(item.into()); +impl<'a, Message: Clone + 'static> Section<'a, Message> { + /// Define an optional title for the section. + pub fn title(self, title: impl Into>) -> Self { + self.header(text::heading(title.into())) + } + + /// Define an optional custom header for the section. + pub fn header(mut self, header: impl Into>) -> Self { + self.header = Some(header.into()); self } - /// Define an optional title for the section. - pub fn title(mut self, title: impl Into>) -> Self { - self.title = title.into(); + /// Add a child element to the section's list column. + #[allow(clippy::should_implement_trait)] + pub fn add(mut self, item: impl IntoListItem<'a, Message>) -> Self { + self.children = self.children.add(item); self } + + /// Add a child element to the section's list column, if `Some`. + pub fn add_maybe(self, item: Option>) -> Self { + if let Some(item) = item { + self.add(item) + } else { + self + } + } + + /// Extends the [`Section`] with the given children. + pub fn extend( + self, + children: impl IntoIterator>, + ) -> Self { + children.into_iter().fold(self, Self::add) + } } -impl<'a, Message: 'static> From> for Element<'a, Message> { +impl<'a, Message: Clone + 'static> From> for Element<'a, Message> { fn from(data: Section<'a, Message>) -> Self { column::with_capacity(2) .spacing(8) - .push_maybe(if data.title.is_empty() { - None - } else { - Some(text::heading(data.title)) - }) + .push_maybe(data.header) .push(data.children) .into() } diff --git a/src/widget/spin_button.rs b/src/widget/spin_button.rs new file mode 100644 index 00000000..833e90b8 --- /dev/null +++ b/src/widget/spin_button.rs @@ -0,0 +1,326 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! A control for incremental adjustments of a value. + +use crate::{ + Element, theme, + widget::{button, column, container, icon, row, text}, +}; +use apply::Apply; +use iced::{Alignment, Length}; +use iced::{Border, Shadow}; +use std::borrow::Cow; +use std::ops::{Add, Sub}; + +/// Horizontal spin button widget. +pub fn spin_button<'a, T, M>( + label: impl Into>, + #[cfg(feature = "a11y")] name: impl Into>, + value: T, + step: T, + min: T, + max: T, + on_press: impl Fn(T) -> M + 'static, +) -> SpinButton<'a, T, M> +where + T: Copy + Sub + Add + PartialOrd, +{ + let mut button = SpinButton::new( + label, + value, + step, + min, + max, + Orientation::Horizontal, + on_press, + ); + + #[cfg(feature = "a11y")] + { + button = button.name(name.into()); + } + + button +} + +/// Vertical spin button widget. +pub fn vertical<'a, T, M>( + label: impl Into>, + #[cfg(feature = "a11y")] name: impl Into>, + value: T, + step: T, + min: T, + max: T, + on_press: impl Fn(T) -> M + 'static, +) -> SpinButton<'a, T, M> +where + T: Copy + Sub + Add + PartialOrd, +{ + let mut button = SpinButton::new( + label, + value, + step, + min, + max, + Orientation::Horizontal, + on_press, + ); + + #[cfg(feature = "a11y")] + { + button = button.name(name.into()); + } + + button +} + +#[derive(Clone, Copy)] +enum Orientation { + Horizontal, + Vertical, +} + +pub struct SpinButton<'a, T, M> +where + T: Copy + Sub + Add + PartialOrd, +{ + /// The formatted value of the spin button. + label: Cow<'a, str>, + /// A name for screen reader support. + #[cfg(feature = "a11y")] + name: Cow<'a, str>, + /// The current value of the spin button. + value: T, + /// The amount to increment or decrement the value. + step: T, + /// The minimum value permitted. + min: T, + /// The maximum value permitted. + max: T, + orientation: Orientation, + on_press: Box M>, +} + +impl<'a, T, M> SpinButton<'a, T, M> +where + T: Copy + Sub + Add + PartialOrd, +{ + /// Create a new new button + fn new( + label: impl Into>, + value: T, + step: T, + min: T, + max: T, + orientation: Orientation, + on_press: impl Fn(T) -> M + 'static, + ) -> Self { + Self { + label: label.into(), + #[cfg(feature = "a11y")] + name: Cow::Borrowed(""), + step, + value: if value < min { + min + } else if value > max { + max + } else { + value + }, + min, + max, + orientation, + on_press: Box::from(on_press), + } + } + + #[cfg(feature = "a11y")] + pub(self) fn name(mut self, name: Cow<'a, str>) -> Self { + self.name = name; + self + } +} + +fn increment(value: T, step: T, _min: T, max: T) -> T +where + T: Copy + Sub + Add + PartialOrd, +{ + if value > max - step { + max + } else { + value + step + } +} + +fn decrement(value: T, step: T, min: T, _max: T) -> T +where + T: Copy + Sub + Add + PartialOrd, +{ + if value < min + step { + min + } else { + value - step + } +} + +impl<'a, T, Message> From> for Element<'a, Message> +where + Message: Clone + 'static, + T: Copy + Sub + Add + PartialOrd, +{ + fn from(this: SpinButton<'a, T, Message>) -> Self { + match this.orientation { + Orientation::Horizontal => horizontal_variant(this), + Orientation::Vertical => vertical_variant(this), + } + } +} + +fn make_button<'a, T, Message>( + spin_button: &SpinButton<'a, T, Message>, + icon: &'static str, + #[cfg(feature = "a11y")] name: String, + operation: Option T>, +) -> Element<'a, Message> +where + Message: Clone + 'static, + T: Copy + Sub + Add + PartialOrd, +{ + let mut button = icon::from_name(icon).apply(button::icon); + + if let Some(f) = operation { + button = button.on_press((spin_button.on_press)(f( + spin_button.value, + spin_button.step, + spin_button.min, + spin_button.max, + ))) + }; + + #[cfg(feature = "a11y")] + { + button = button.name(name.clone()); + } + + button.into() +} + +fn horizontal_variant(spin_button: SpinButton<'_, T, Message>) -> Element<'_, Message> +where + Message: Clone + 'static, + T: Copy + Sub + Add + PartialOrd, +{ + let decrement_button = make_button( + &spin_button, + "list-remove-symbolic", + #[cfg(feature = "a11y")] + [&spin_button.name, " decrease"].concat(), + match spin_button.value == spin_button.min { + true => None, + false => Some(decrement), + }, + ); + let increment_button = make_button( + &spin_button, + "list-add-symbolic", + #[cfg(feature = "a11y")] + [&spin_button.name, " increase"].concat(), + match spin_button.value == spin_button.max { + true => None, + false => Some(increment), + }, + ); + let label = text::body(spin_button.label) + .apply(container) + .center_x(Length::Fixed(48.0)) + .align_y(Alignment::Center); + + row::with_capacity(3) + .push(decrement_button) + .push(label) + .push(increment_button) + .align_y(Alignment::Center) + .apply(container) + .class(theme::Container::custom(container_style)) + .into() +} + +fn vertical_variant(spin_button: SpinButton<'_, T, Message>) -> Element<'_, Message> +where + Message: Clone + 'static, + T: Copy + Sub + Add + PartialOrd, +{ + let decrement_button = make_button( + &spin_button, + "list-remove-symbolic", + #[cfg(feature = "a11y")] + [&spin_button.label, " decrease"].concat(), + match spin_button.value == spin_button.min { + true => None, + false => Some(decrement), + }, + ); + let increment_button = make_button( + &spin_button, + "list-add-symbolic", + #[cfg(feature = "a11y")] + [&spin_button.label, " increase"].concat(), + match spin_button.value == spin_button.max { + true => None, + false => Some(increment), + }, + ); + + let label = text::body(spin_button.label) + .apply(container) + .center_x(Length::Fixed(48.0)) + .align_y(Alignment::Center); + + column::with_capacity(3) + .push(increment_button) + .push(label) + .push(decrement_button) + .align_x(Alignment::Center) + .apply(container) + .class(theme::Container::custom(container_style)) + .into() +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn container_style(theme: &crate::Theme) -> iced_widget::container::Style { + let cosmic_theme = &theme.cosmic(); + let accent = &cosmic_theme.accent; + let corners = &cosmic_theme.corner_radii; + let current_container = theme.current_container(); + let border = if theme.theme_type.is_high_contrast() { + Border { + radius: corners.radius_s.into(), + width: 1., + color: current_container.component.border.into(), + } + } else { + Border { + radius: corners.radius_s.into(), + width: 0.0, + color: accent.base.into(), + } + }; + + iced_widget::container::Style { + icon_color: Some(current_container.on.into()), + text_color: Some(current_container.on.into()), + background: None, + border, + shadow: Shadow::default(), + snap: true, + } +} + +#[cfg(test)] +mod tests { + #[test] + fn decrement() { + assert_eq!(super::decrement(0i32, 10, 15, 35), 15); + } +} diff --git a/src/widget/spin_button/mod.rs b/src/widget/spin_button/mod.rs deleted file mode 100644 index d0fbbca2..00000000 --- a/src/widget/spin_button/mod.rs +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright 2022 System76 -// SPDX-License-Identifier: MPL-2.0 - -//! A control for incremental adjustments of a value. - -mod model; -use std::borrow::Cow; - -pub use self::model::{Message, Model}; - -use crate::widget::{button, container, icon, row, text}; -use crate::{theme, Element}; -use apply::Apply; -use iced::{ - alignment::{Horizontal, Vertical}, - Alignment, Length, -}; -use iced_core::{Border, Shadow}; - -pub struct SpinButton<'a, Message> { - label: Cow<'a, str>, - on_change: Box Message + 'static>, -} - -/// A control for incremental adjustments of a value. -pub fn spin_button<'a, Message: 'static>( - label: impl Into>, - on_change: impl Fn(model::Message) -> Message + 'static, -) -> SpinButton<'a, Message> { - SpinButton::new(label, on_change) -} - -impl<'a, Message: 'static> SpinButton<'a, Message> { - pub fn new( - label: impl Into>, - on_change: impl Fn(model::Message) -> Message + 'static, - ) -> Self { - Self { - on_change: Box::from(on_change), - label: label.into(), - } - } - - #[must_use] - pub fn into_element(self) -> Element<'a, Message> { - let Self { on_change, label } = self; - container( - row::with_children(vec![ - icon::from_name("list-remove-symbolic") - .size(16) - .apply(container) - .width(Length::Fixed(32.0)) - .height(Length::Fixed(32.0)) - .align_x(Horizontal::Center) - .align_y(Vertical::Center) - .apply(button::custom) - .width(Length::Fixed(32.0)) - .height(Length::Fixed(32.0)) - .style(theme::Button::Text) - .on_press(model::Message::Decrement) - .into(), - text::title4(label) - .vertical_alignment(Vertical::Center) - .apply(container) - .width(Length::Fixed(48.0)) - .align_x(Horizontal::Center) - .align_y(Vertical::Center) - .into(), - icon::from_name("list-add-symbolic") - .size(16) - .apply(container) - .width(Length::Fixed(32.0)) - .height(Length::Fixed(32.0)) - .align_x(Horizontal::Center) - .align_y(Vertical::Center) - .apply(button::custom) - .width(Length::Fixed(32.0)) - .height(Length::Fixed(32.0)) - .style(theme::Button::Text) - .on_press(model::Message::Increment) - .into(), - ]) - .width(Length::Shrink) - .height(Length::Fixed(32.0)) - .align_items(Alignment::Center), - ) - .align_y(Vertical::Center) - .width(Length::Shrink) - .height(Length::Fixed(32.0)) - .style(theme::Container::custom(container_style)) - .apply(Element::from) - .map(on_change) - } -} - -impl<'a, Message: 'static> From> for Element<'a, Message> { - fn from(spin_button: SpinButton<'a, Message>) -> Self { - spin_button.into_element() - } -} - -#[allow(clippy::trivially_copy_pass_by_ref)] -fn container_style(theme: &crate::Theme) -> iced_style::container::Appearance { - let basic = &theme.cosmic(); - let mut neutral_10 = basic.palette.neutral_10; - neutral_10.alpha = 0.1; - let accent = &basic.accent; - let corners = &basic.corner_radii; - iced_style::container::Appearance { - icon_color: Some(basic.palette.neutral_10.into()), - text_color: Some(basic.palette.neutral_10.into()), - background: None, - border: Border { - radius: corners.radius_s.into(), - width: 0.0, - color: accent.base.into(), - }, - shadow: Shadow::default(), - } -} diff --git a/src/widget/spin_button/model.rs b/src/widget/spin_button/model.rs deleted file mode 100644 index e617bc87..00000000 --- a/src/widget/spin_button/model.rs +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright 2022 System76 -// SPDX-License-Identifier: MPL-2.0 - -use derive_setters::Setters; -use fraction::{Bounded, Decimal}; -use std::hash::Hash; -use std::ops::{Add, Sub}; - -/// A message emitted by the [`SpinButton`](super) widget. -#[derive(Clone, Copy, Debug, Hash)] -pub enum Message { - Increment, - Decrement, -} - -#[derive(Setters)] -pub struct Model { - /// The current value of the spin button. - #[setters(into)] - pub value: T, - /// The amount to increment the value. - #[setters(into)] - pub step: T, - /// The minimum value permitted. - #[setters(into)] - pub min: T, - /// The maximum value permitted. - #[setters(into)] - pub max: T, -} - -impl Model -where - T: Copy + Hash + Sub + Add + Ord, -{ - pub fn update(&mut self, message: Message) { - self.value = match message { - Message::Increment => { - std::cmp::min(std::cmp::max(self.value + self.step, self.min), self.max) - } - Message::Decrement => { - std::cmp::max(std::cmp::min(self.value - self.step, self.max), self.min) - } - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: i8::MIN, - max: i8::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: i16::MIN, - max: i16::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: i32::MIN, - max: i32::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: isize::MIN, - max: isize::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: u8::MIN, - max: u8::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: u16::MIN, - max: u16::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: u32::MIN, - max: u32::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: usize::MIN, - max: usize::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: 0, - step: 1, - min: u64::MIN, - max: u64::MAX, - } - } -} - -impl Default for Model { - fn default() -> Self { - Self { - value: Decimal::from(0.0), - step: Decimal::from(0.0), - min: Decimal::min_positive_value(), - max: Decimal::max_value(), - } - } -} diff --git a/src/widget/tab_bar.rs b/src/widget/tab_bar.rs index 0f17be96..a08128b4 100644 --- a/src/widget/tab_bar.rs +++ b/src/widget/tab_bar.rs @@ -16,13 +16,12 @@ use super::segmented_button::{ /// For details on the model, see the [`segmented_button`] module for more details. pub fn horizontal( model: &Model, -) -> HorizontalSegmentedButton +) -> HorizontalSegmentedButton<'_, SelectionMode, Message> where Model: Selectable, { - let theme = crate::theme::active(); - let space_s = theme.cosmic().space_s(); - let space_xs = theme.cosmic().space_xs(); + let space_s = crate::theme::spacing().space_s; + let space_xs = crate::theme::spacing().space_xs; segmented_button::horizontal(model) .minimum_button_width(76) @@ -30,7 +29,6 @@ where .button_height(44) .button_padding([space_s, space_xs, space_s, space_xs]) .style(crate::theme::SegmentedButton::TabBar) - .font_active(Some(crate::font::semibold())) } /// A collection of tabs for developing a tabbed interface. @@ -39,14 +37,13 @@ where /// For details on the model, see the [`segmented_button`] module for more details. pub fn vertical( model: &Model, -) -> VerticalSegmentedButton +) -> VerticalSegmentedButton<'_, SelectionMode, Message> where Model: Selectable, SelectionMode: Default, { - let theme = crate::theme::active(); - let space_s = theme.cosmic().space_s(); - let space_xs = theme.cosmic().space_xs(); + let space_s = crate::theme::spacing().space_s; + let space_xs = crate::theme::spacing().space_xs; SegmentedButton::new(model) .minimum_button_width(76) @@ -54,5 +51,4 @@ where .button_height(44) .button_padding([space_s, space_xs, space_s, space_xs]) .style(crate::theme::SegmentedButton::TabBar) - .font_active(Some(crate::font::semibold())) } diff --git a/src/widget/table/mod.rs b/src/widget/table/mod.rs new file mode 100644 index 00000000..c546383c --- /dev/null +++ b/src/widget/table/mod.rs @@ -0,0 +1,47 @@ +//! A widget allowing the user to display tables of information with optional sorting by category +//! + +pub mod model; +pub use model::{ + Entity, Model, + category::ItemCategory, + category::ItemInterface, + selection::{MultiSelect, SingleSelect}, +}; +pub mod widget; +pub use widget::compact::CompactTableView; +pub use widget::standard::TableView; + +pub type SingleSelectTableView<'a, Item, Category, Message> = + TableView<'a, SingleSelect, Item, Category, Message>; +pub type SingleSelectModel = Model; + +pub type MultiSelectTableView<'a, Item, Category, Message> = + TableView<'a, MultiSelect, Item, Category, Message>; +pub type MultiSelectModel = Model; + +pub fn table( + model: &Model, +) -> TableView<'_, SelectionMode, Item, Category, Message> +where + Message: Clone, + SelectionMode: Default, + Category: ItemCategory, + Item: ItemInterface, + Model: model::selection::Selectable, +{ + TableView::new(model) +} + +pub fn compact_table( + model: &Model, +) -> CompactTableView<'_, SelectionMode, Item, Category, Message> +where + Message: Clone, + SelectionMode: Default, + Category: ItemCategory, + Item: ItemInterface, + Model: model::selection::Selectable, +{ + CompactTableView::new(model) +} diff --git a/src/widget/table/model/category.rs b/src/widget/table/model/category.rs new file mode 100644 index 00000000..e9bb7477 --- /dev/null +++ b/src/widget/table/model/category.rs @@ -0,0 +1,19 @@ +use std::borrow::Cow; + +use crate::widget::Icon; + +/// Implementation of std::fmt::Display allows user to customize the header +/// Ideally, this is implemented on an enum. +pub trait ItemCategory: + Default + std::fmt::Display + Clone + Copy + PartialEq + Eq + std::hash::Hash +{ + /// Function that gets the width of the data + fn width(&self) -> iced::Length; +} + +pub trait ItemInterface { + fn get_icon(&self, category: Category) -> Option; + fn get_text(&self, category: Category) -> Cow<'static, str>; + + fn compare(&self, other: &Self, category: Category) -> std::cmp::Ordering; +} diff --git a/src/widget/table/model/entity.rs b/src/widget/table/model/entity.rs new file mode 100644 index 00000000..51c60609 --- /dev/null +++ b/src/widget/table/model/entity.rs @@ -0,0 +1,127 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use slotmap::{SecondaryMap, SparseSecondaryMap}; + +use super::{ + Entity, Model, Selectable, + category::{ItemCategory, ItemInterface}, +}; + +/// A newly-inserted item which may have additional actions applied to it. +pub struct EntityMut< + 'a, + SelectionMode: Default, + Item: ItemInterface, + Category: ItemCategory, +> { + pub(super) id: Entity, + pub(super) model: &'a mut Model, +} + +impl<'a, SelectionMode: Default, Item: ItemInterface, Category: ItemCategory> + EntityMut<'a, SelectionMode, Item, Category> +where + Model: Selectable, +{ + /// Activates the newly-inserted item. + /// + /// ```ignore + /// model.insert().text("Item A").activate(); + /// ``` + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn activate(self) -> Self { + self.model.activate(self.id); + self + } + + /// Associates extra data with an external secondary map. + /// + /// The secondary map internally uses a `Vec`, so should only be used for data that + /// is commonly associated. + /// + /// ```ignore + /// let mut secondary_data = segmented_button::SecondaryMap::default(); + /// model.insert().text("Item A").secondary(&mut secondary_data, String::new("custom data")); + /// ``` + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn secondary(self, map: &mut SecondaryMap, data: Data) -> Self { + map.insert(self.id, data); + self + } + + /// Associates extra data with an external sparse secondary map. + /// + /// Sparse maps internally use a `HashMap`, for data that is sparsely associated. + /// + /// ```ignore + /// let mut secondary_data = segmented_button::SparseSecondaryMap::default(); + /// model.insert().text("Item A").secondary(&mut secondary_data, String::new("custom data")); + /// ``` + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn secondary_sparse( + self, + map: &mut SparseSecondaryMap, + data: Data, + ) -> Self { + map.insert(self.id, data); + self + } + + /// Associates data with the item. + /// + /// There may only be one data component per Rust type. + /// + /// ```ignore + /// model.insert().text("Item A").data(String::from("custom string")); + /// ``` + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn data(self, data: Data) -> Self { + self.model.data_set(self.id, data); + self + } + + /// Returns the ID of the item that was inserted. + /// + /// ```ignore + /// let id = model.insert("Item A").id(); + /// ``` + #[must_use] + pub fn id(self) -> Entity { + self.id + } + + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn indent(self, indent: u16) -> Self { + self.model.indent_set(self.id, indent); + self + } + + /// Define the position of the item. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn position(self, position: u16) -> Self { + self.model.position_set(self.id, position); + self + } + + /// Swap the position with another item in the model. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn position_swap(self, other: Entity) -> Self { + self.model.position_swap(self.id, other); + self + } + + /// Defines the text for the item. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn item(self, item: Item) -> Self { + self.model.item_set(self.id, item); + self + } + + /// Calls a function with the ID without consuming the wrapper. + #[allow(clippy::must_use_candidate, clippy::return_self_not_must_use)] + pub fn with_id(self, func: impl FnOnce(Entity)) -> Self { + func(self.id); + self + } +} diff --git a/src/widget/table/model/mod.rs b/src/widget/table/model/mod.rs new file mode 100644 index 00000000..d6250eaf --- /dev/null +++ b/src/widget/table/model/mod.rs @@ -0,0 +1,364 @@ +pub mod category; +pub mod entity; +pub mod selection; + +use std::{ + any::{Any, TypeId}, + collections::{HashMap, VecDeque}, +}; + +use category::{ItemCategory, ItemInterface}; +use entity::EntityMut; +use selection::Selectable; +use slotmap::{SecondaryMap, SlotMap}; + +slotmap::new_key_type! { + /// Unique key type for items in the table + pub struct Entity; +} + +/// The portion of the model used only by the application. +#[derive(Debug, Default)] +pub(super) struct Storage(HashMap>>); + +pub struct Model, Category: ItemCategory> +where + Category: ItemCategory, +{ + pub(super) categories: Vec, + + /// Stores the items + pub(super) items: SlotMap, + + /// Whether the item is selected or not + pub(super) active: SecondaryMap, + + /// Optional indents for the table items + pub(super) indents: SecondaryMap, + + /// Order which the items will be displayed. + pub(super) order: VecDeque, + + /// Stores the current selection(s) + pub(super) selection: SelectionMode, + + /// What category to sort by and whether it's ascending or not + pub(super) sort: Option<(Category, bool)>, + + /// Application-managed data associated with each item + pub(super) storage: Storage, +} + +impl, Category: ItemCategory> + Model +where + Self: Selectable, +{ + pub fn new(categories: Vec) -> Self { + Self { + categories, + items: SlotMap::default(), + active: SecondaryMap::default(), + indents: SecondaryMap::default(), + order: VecDeque::new(), + selection: SelectionMode::default(), + sort: None, + storage: Storage::default(), + } + } + + pub fn categories(&mut self, cats: Vec) { + self.categories = cats; + } + + /// Activates the item in the model. + /// + /// ```ignore + /// model.activate(id); + /// ``` + pub fn activate(&mut self, id: Entity) { + Selectable::activate(self, id); + } + + /// Activates the item at the given position, returning true if it was activated. + pub fn activate_position(&mut self, position: u16) -> bool { + if let Some(entity) = self.entity_at(position) { + self.activate(entity); + return true; + } + + false + } + + /// Removes all items from the model. + /// + /// Any IDs held elsewhere by the application will no longer be usable with the map. + /// The generation is incremented on removal, so the stale IDs will return `None` for + /// any attempt to get values from the map. + /// + /// ```ignore + /// model.clear(); + /// ``` + pub fn clear(&mut self) { + for entity in self.order.clone() { + self.remove(entity); + } + } + + /// Check if an item exists in the map. + /// + /// ```ignore + /// if model.contains_item(id) { + /// println!("ID is still valid"); + /// } + /// ``` + pub fn contains_item(&self, id: Entity) -> bool { + self.items.contains_key(id) + } + + /// Get an immutable reference to data associated with an item. + /// + /// ```ignore + /// if let Some(data) = model.data::(id) { + /// println!("found string on {:?}: {}", id, data); + /// } + /// ``` + pub fn item(&self, id: Entity) -> Option<&Item> { + self.items.get(id) + } + + /// Get a mutable reference to data associated with an item. + pub fn item_mut(&mut self, id: Entity) -> Option<&mut Item> { + self.items.get_mut(id) + } + + /// Associates data with the item. + /// + /// There may only be one data component per Rust type. + /// + /// ```ignore + /// model.data_set::(id, String::from("custom string")); + /// ``` + pub fn item_set(&mut self, id: Entity, data: Item) { + if let Some(item) = self.items.get_mut(id) { + *item = data; + } + } + + /// Get an immutable reference to data associated with an item. + /// + /// ```ignore + /// if let Some(data) = model.data::(id) { + /// println!("found string on {:?}: {}", id, data); + /// } + /// ``` + pub fn data(&self, id: Entity) -> Option<&Data> { + self.storage + .0 + .get(&TypeId::of::()) + .and_then(|storage| storage.get(id)) + .and_then(|data| data.downcast_ref()) + } + + /// Get a mutable reference to data associated with an item. + pub fn data_mut(&mut self, id: Entity) -> Option<&mut Data> { + self.storage + .0 + .get_mut(&TypeId::of::()) + .and_then(|storage| storage.get_mut(id)) + .and_then(|data| data.downcast_mut()) + } + + /// Associates data with the item. + /// + /// There may only be one data component per Rust type. + /// + /// ```ignore + /// model.data_set::(id, String::from("custom string")); + /// ``` + pub fn data_set(&mut self, id: Entity, data: Data) { + if self.contains_item(id) { + self.storage + .0 + .entry(TypeId::of::()) + .or_default() + .insert(id, Box::new(data)); + } + } + + /// Removes a specific data type from the item. + /// + /// ```ignore + /// model.data.remove::(id); + /// ``` + pub fn data_remove(&mut self, id: Entity) { + self.storage + .0 + .get_mut(&TypeId::of::()) + .and_then(|storage| storage.remove(id)); + } + + /// Enable or disable an item. + /// + /// ```ignore + /// model.enable(id, true); + /// ``` + pub fn enable(&mut self, id: Entity, enable: bool) { + if let Some(e) = self.active.get_mut(id) { + *e = enable; + } + } + + /// Get the item that is located at a given position. + #[must_use] + pub fn entity_at(&mut self, position: u16) -> Option { + self.order.get(position as usize).copied() + } + + /// Inserts a new item in the model. + /// + /// ```ignore + /// let id = model.insert().text("Item A").icon("custom-icon").id(); + /// ``` + #[must_use] + pub fn insert(&mut self, item: Item) -> EntityMut<'_, SelectionMode, Item, Category> { + let id = self.items.insert(item); + self.order.push_back(id); + EntityMut { model: self, id } + } + + /// Check if the given ID is the active ID. + #[must_use] + pub fn is_active(&self, id: Entity) -> bool { + ::is_active(self, id) + } + + /// Check if the item is enabled. + /// + /// ```ignore + /// if model.is_enabled(id) { + /// if let Some(text) = model.text(id) { + /// println!("{text} is enabled"); + /// } + /// } + /// ``` + #[must_use] + pub fn is_enabled(&self, id: Entity) -> bool { + self.active.get(id).is_some_and(|e| *e) + } + + /// Iterates across items in the model in the order that they are displayed. + pub fn iter(&self) -> impl Iterator + '_ { + self.order.iter().copied() + } + + pub fn indent(&self, id: Entity) -> Option { + self.indents.get(id).copied() + } + + pub fn indent_set(&mut self, id: Entity, indent: u16) -> Option { + if !self.contains_item(id) { + return None; + } + + self.indents.insert(id, indent) + } + + pub fn indent_remove(&mut self, id: Entity) -> Option { + self.indents.remove(id) + } + + /// The position of the item in the model. + /// + /// ```ignore + /// if let Some(position) = model.position(id) { + /// println!("found item at {}", position); + /// } + #[must_use] + pub fn position(&self, id: Entity) -> Option { + #[allow(clippy::cast_possible_truncation)] + self.order.iter().position(|k| *k == id).map(|v| v as u16) + } + + /// Change the position of an item in the model. + /// + /// ```ignore + /// if let Some(new_position) = model.position_set(id, 0) { + /// println!("placed item at {}", new_position); + /// } + /// ``` + pub fn position_set(&mut self, id: Entity, position: u16) -> Option { + let index = self.position(id)?; + + self.order.remove(index as usize); + + let position = self.order.len().min(position as usize); + + self.order.insert(position, id); + Some(position) + } + + /// Swap the position of two items in the model. + /// + /// Returns false if the swap cannot be performed. + /// + /// ```ignore + /// if model.position_swap(first_id, second_id) { + /// println!("positions swapped"); + /// } + /// ``` + pub fn position_swap(&mut self, first: Entity, second: Entity) -> bool { + let Some(first_index) = self.position(first) else { + return false; + }; + + let Some(second_index) = self.position(second) else { + return false; + }; + + self.order.swap(first_index as usize, second_index as usize); + true + } + + /// Removes an item from the model. + /// + /// The generation of the slot for the ID will be incremented, so this ID will no + /// longer be usable with the map. Subsequent attempts to get values from the map + /// with this ID will return `None` and failed to assign values. + pub fn remove(&mut self, id: Entity) { + self.items.remove(id); + self.deactivate(id); + + for storage in self.storage.0.values_mut() { + storage.remove(id); + } + + if let Some(index) = self.position(id) { + self.order.remove(index as usize); + } + } + + /// Get the sort data + pub fn get_sort(&self) -> Option<(Category, bool)> { + self.sort + } + + /// Sorts items in the model, this should be called before it is drawn after all items have been added for the view + pub fn sort(&mut self, category: Category, ascending: bool) { + match self.sort { + Some((cat, asc)) if cat == category && asc == ascending => return, + Some((cat, _)) if cat == category => self.order.make_contiguous().reverse(), + _ => { + self.order.make_contiguous().sort_by(|entity_a, entity_b| { + let cmp = self + .items + .get(*entity_a) + .unwrap() + .compare(self.items.get(*entity_b).unwrap(), category); + if ascending { cmp } else { cmp.reverse() } + }); + } + } + self.sort = Some((category, ascending)); + } +} diff --git a/src/widget/table/model/selection.rs b/src/widget/table/model/selection.rs new file mode 100644 index 00000000..20a07248 --- /dev/null +++ b/src/widget/table/model/selection.rs @@ -0,0 +1,115 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Describes logic specific to the single-select and multi-select modes of a model. + +use super::{ + Entity, Model, + category::{ItemCategory, ItemInterface}, +}; +use std::collections::HashSet; + +/// Describes a type that has selectable items. +pub trait Selectable { + /// Activate an item. + fn activate(&mut self, id: Entity); + + /// Deactivate an item. + fn deactivate(&mut self, id: Entity); + + /// Checks if the item is active. + fn is_active(&self, id: Entity) -> bool; +} + +/// [`Model`] Ensures that only one key may be selected. +#[derive(Debug, Default)] +pub struct SingleSelect { + pub active: Entity, +} + +impl, Category: ItemCategory> Selectable + for Model +{ + fn activate(&mut self, id: Entity) { + if !self.items.contains_key(id) { + return; + } + + self.selection.active = id; + } + + fn deactivate(&mut self, id: Entity) { + if id == self.selection.active { + self.selection.active = Entity::default(); + } + } + + fn is_active(&self, id: Entity) -> bool { + self.selection.active == id + } +} + +impl, Category: ItemCategory> Model { + /// Get an immutable reference to the data associated with the active item. + #[must_use] + pub fn active_data(&self) -> Option<&Data> { + self.data(self.active()) + } + + /// Get a mutable reference to the data associated with the active item. + #[must_use] + pub fn active_data_mut(&mut self) -> Option<&mut Data> { + self.data_mut(self.active()) + } + + /// Deactivates the active item. + pub fn deactivate(&mut self) { + Selectable::deactivate(self, Entity::default()); + } + + /// The ID of the active item. + #[must_use] + pub fn active(&self) -> Entity { + self.selection.active + } +} + +/// [`Model`] permits multiple keys to be active at a time. +#[derive(Debug, Default)] +pub struct MultiSelect { + pub active: HashSet, +} + +impl, Category: ItemCategory> Selectable + for Model +{ + fn activate(&mut self, id: Entity) { + if !self.items.contains_key(id) { + return; + } + + if !self.selection.active.insert(id) { + self.selection.active.remove(&id); + } + } + + fn deactivate(&mut self, id: Entity) { + self.selection.active.remove(&id); + } + + fn is_active(&self, id: Entity) -> bool { + self.selection.active.contains(&id) + } +} + +impl, Category: ItemCategory> Model { + /// Deactivates the item in the model. + pub fn deactivate(&mut self, id: Entity) { + Selectable::deactivate(self, id); + } + + /// The IDs of the active items. + pub fn active(&self) -> impl Iterator + '_ { + self.selection.active.iter().copied() + } +} diff --git a/src/widget/table/widget/compact.rs b/src/widget/table/widget/compact.rs new file mode 100644 index 00000000..65ac9058 --- /dev/null +++ b/src/widget/table/widget/compact.rs @@ -0,0 +1,255 @@ +use derive_setters::Setters; + +use crate::widget::table::model::{ + Entity, Model, + category::{ItemCategory, ItemInterface}, + selection::Selectable, +}; +use crate::{ + Apply, Element, theme, + widget::{self, container, menu}, +}; +use iced::{Alignment, Border, Padding}; + +#[derive(Setters)] +#[must_use] +pub struct CompactTableView<'a, SelectionMode, Item, Category, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + pub(super) model: &'a Model, + + #[setters(into)] + pub(super) element_padding: Padding, + + #[setters(into)] + pub(super) item_padding: Padding, + pub(super) item_spacing: u16, + pub(super) icon_size: u16, + + #[setters(into)] + pub(super) divider_padding: Padding, + + // === Item Interaction === + #[setters(skip)] + pub(super) on_item_mb_left: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_double: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_mid: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_right: Option Message + 'static>>, + #[setters(skip)] + pub(super) item_context_builder: Box Option>>>, +} + +impl<'a, SelectionMode, Item, Category, Message> + From> for Element<'a, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + fn from(val: CompactTableView<'a, SelectionMode, Item, Category, Message>) -> Self { + let cosmic_theme::Spacing { space_xxxs, .. } = theme::spacing(); + val.model + .iter() + .map(|entity| { + let item = val.model.item(entity).unwrap(); + let selected = val.model.is_active(entity); + let context_menu = (val.item_context_builder)(item); + + widget::column::with_capacity(2) + .spacing(val.item_spacing) + .push( + widget::divider::horizontal::default() + .apply(container) + .padding(val.divider_padding), + ) + .push( + widget::row::with_capacity(2) + .spacing(space_xxxs) + .align_y(Alignment::Center) + .push_maybe( + item.get_icon(Category::default()) + .map(|icon| icon.size(val.icon_size)), + ) + .push( + widget::column::with_capacity(2) + .push(widget::text::body(item.get_text(Category::default()))) + .push({ + let mut elements = val + .model + .categories + .iter() + .skip_while(|cat| **cat != Category::default()) + .flat_map(|category| { + [ + widget::text::caption(item.get_text(*category)) + .apply(Element::from), + widget::text::caption("-").apply(Element::from), + ] + }) + .collect::>>(); + elements.pop(); + elements + .apply(widget::row::with_children) + .spacing(space_xxxs) + .wrap() + }), + ) + .apply(container) + .padding(val.item_padding) + .width(iced::Length::Fill) + .class(theme::Container::custom(move |theme| { + widget::container::Style { + icon_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + text_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + background: if selected { + Some(iced::Background::Color( + theme.cosmic().accent_color().into(), + )) + } else { + None + }, + border: Border { + radius: theme.cosmic().radius_xs().into(), + ..Default::default() + }, + shadow: Default::default(), + snap: true, + } + })) + .apply(widget::mouse_area) + // Left click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_left { + mouse_area.on_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Double click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_double { + mouse_area.on_double_click((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Middle click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_mid { + mouse_area.on_middle_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Right click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_right { + mouse_area.on_right_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + .apply(|ma| widget::context_menu(ma, context_menu)), + ) + .apply(Element::from) + }) + .apply(widget::column::with_children) + .spacing(val.item_spacing) + .padding(val.element_padding) + .apply(Element::from) + } +} + +impl<'a, SelectionMode, Item, Category, Message> + CompactTableView<'a, SelectionMode, Item, Category, Message> +where + SelectionMode: Default, + Model: Selectable, + Category: ItemCategory, + Item: ItemInterface, + Message: Clone + 'static, +{ + pub fn new(model: &'a Model) -> Self { + let cosmic_theme::Spacing { + space_xxxs, + space_xxs, + .. + } = theme::spacing(); + + Self { + model, + element_padding: Padding::from(0), + + divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), + + item_padding: Padding::from(space_xxs), + item_spacing: 0, + icon_size: 48, + + on_item_mb_left: None, + on_item_mb_double: None, + on_item_mb_mid: None, + on_item_mb_right: None, + item_context_builder: Box::new(|_| None), + } + } + + pub fn on_item_left_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_left = Some(Box::new(on_click)); + self + } + + pub fn on_item_double_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_double = Some(Box::new(on_click)); + self + } + + pub fn on_item_middle_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_mid = Some(Box::new(on_click)); + self + } + + pub fn on_item_right_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_right = Some(Box::new(on_click)); + self + } + + pub fn item_context(mut self, context_menu_builder: F) -> Self + where + F: Fn(&Item) -> Option>> + 'static, + Message: 'static, + { + self.item_context_builder = Box::new(context_menu_builder); + self + } +} diff --git a/src/widget/table/widget/mod.rs b/src/widget/table/widget/mod.rs new file mode 100644 index 00000000..0396796e --- /dev/null +++ b/src/widget/table/widget/mod.rs @@ -0,0 +1,2 @@ +pub mod compact; +pub mod standard; diff --git a/src/widget/table/widget/standard.rs b/src/widget/table/widget/standard.rs new file mode 100644 index 00000000..9ab76c9d --- /dev/null +++ b/src/widget/table/widget/standard.rs @@ -0,0 +1,372 @@ +use derive_setters::Setters; + +use crate::widget::table::model::{ + Entity, Model, + category::{ItemCategory, ItemInterface}, + selection::Selectable, +}; +use crate::{ + Apply, Element, theme, + widget::{self, container, divider, menu}, +}; +use iced::{Alignment, Border, Length, Padding}; + +// THIS IS A PLACEHOLDER UNTIL A MORE SOPHISTICATED WIDGET CAN BE DEVELOPED + +#[derive(Setters)] +#[must_use] +pub struct TableView<'a, SelectionMode, Item, Category, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + pub(super) model: &'a Model, + + #[setters(into)] + pub(super) element_padding: Padding, + #[setters(into)] + pub(super) width: Length, + #[setters(into)] + pub(super) height: Length, + + #[setters(into)] + pub(super) item_padding: Padding, + pub(super) item_spacing: u16, + pub(super) icon_spacing: u16, + pub(super) icon_size: u16, + + #[setters(into)] + pub(super) divider_padding: Padding, + + // === Item Interaction === + #[setters(skip)] + pub(super) on_item_mb_left: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_double: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_mid: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_item_mb_right: Option Message + 'static>>, + #[setters(skip)] + pub(super) item_context_builder: Box Option>>>, + // Item DND + + // === Category Interaction === + #[setters(skip)] + pub(super) on_category_mb_left: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_category_mb_double: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_category_mb_mid: Option Message + 'static>>, + #[setters(skip)] + pub(super) on_category_mb_right: Option Message + 'static>>, + #[setters(skip)] + pub(super) category_context_builder: Box Option>>>, +} + +impl<'a, SelectionMode, Item, Category, Message> + From> for Element<'a, Message> +where + Category: ItemCategory, + Item: ItemInterface, + Model: Selectable, + SelectionMode: Default, + Message: Clone + 'static, +{ + fn from(val: TableView<'a, SelectionMode, Item, Category, Message>) -> Self { + // Header row + let header_row = val + .model + .categories + .iter() + .copied() + .map(|category| { + let cat_context_tree = (val.category_context_builder)(category); + + let mut sort_state = 0; + + if let Some(sort) = val.model.sort { + if sort.0 == category { + if sort.1 { + sort_state = 1; + } else { + sort_state = 2; + } + } + }; + + // Build the category header + widget::row::with_capacity(2) + .spacing(val.icon_spacing) + .push(widget::text::heading(category.to_string())) + .push_maybe(match sort_state { + 1 => Some(widget::icon::from_name("pan-up-symbolic").icon()), + 2 => Some(widget::icon::from_name("pan-down-symbolic").icon()), + _ => None, + }) + .apply(container) + .padding( + Padding::default() + .left(val.item_padding.left) + .right(val.item_padding.right), + ) + .width(category.width()) + .apply(widget::mouse_area) + .apply(|mouse_area| { + if let Some(ref on_category_select) = val.on_category_mb_left { + mouse_area.on_press((on_category_select)(category)) + } else { + mouse_area + } + }) + .apply(|mouse_area| widget::context_menu(mouse_area, cat_context_tree)) + .apply(Element::from) + }) + .apply(widget::row::with_children) + .apply(Element::from); + // Build the items + let items_full = if val.model.items.is_empty() { + vec![ + divider::horizontal::default() + .apply(container) + .padding(val.divider_padding) + .apply(Element::from), + ] + } else { + val.model + .iter() + .flat_map(move |entity| { + let item = val.model.item(entity).unwrap(); + let categories = &val.model.categories; + let selected = val.model.is_active(entity); + let item_context = (val.item_context_builder)(item); + + [ + divider::horizontal::default() + .apply(container) + .padding(val.divider_padding) + .apply(Element::from), + categories + .iter() + .map(|category| { + widget::row::with_capacity(2) + .spacing(val.icon_spacing) + .push_maybe( + item.get_icon(*category) + .map(|icon| icon.size(val.icon_size)), + ) + .push(widget::text::body(item.get_text(*category))) + .align_y(Alignment::Center) + .apply(container) + .width(category.width()) + .align_y(Alignment::Center) + .apply(Element::from) + }) + .apply(widget::row::with_children) + .apply(container) + .padding(val.item_padding) + .class(theme::Container::custom(move |theme| { + widget::container::Style { + icon_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + text_color: if selected { + Some(theme.cosmic().on_accent_color().into()) + } else { + None + }, + background: if selected { + Some(iced::Background::Color( + theme.cosmic().accent_color().into(), + )) + } else { + None + }, + border: Border { + radius: theme.cosmic().radius_xs().into(), + ..Default::default() + }, + shadow: Default::default(), + snap: true, + } + })) + .apply(widget::mouse_area) + // Left click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_left { + mouse_area.on_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Double click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_double { + mouse_area.on_double_click((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Middle click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_mid { + mouse_area.on_middle_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + // Right click + .apply(|mouse_area| { + if let Some(ref on_item_mb) = val.on_item_mb_right { + mouse_area.on_right_press((on_item_mb)(entity)) + } else { + mouse_area + } + }) + .apply(|mouse_area| widget::context_menu(mouse_area, item_context)) + .apply(Element::from), + ] + }) + .collect::>>() + }; + let mut elements = items_full; + elements.insert(0, header_row); + elements + .apply(widget::column::with_children) + .width(val.width) + .height(val.height) + .spacing(val.item_spacing) + .padding(val.element_padding) + .apply(Element::from) + } +} + +impl<'a, SelectionMode, Item, Category, Message> + TableView<'a, SelectionMode, Item, Category, Message> +where + SelectionMode: Default, + Model: Selectable, + Category: ItemCategory, + Item: ItemInterface, + Message: Clone + 'static, +{ + pub fn new(model: &'a Model) -> Self { + let cosmic_theme::Spacing { + space_xxxs, + space_xxs, + .. + } = theme::spacing(); + + Self { + model, + + element_padding: Padding::from(0), + width: Length::Fill, + height: Length::Shrink, + + item_padding: Padding::from(space_xxs), + item_spacing: 0, + icon_spacing: space_xxxs, + icon_size: 24, + + divider_padding: Padding::from(0).left(space_xxxs).right(space_xxxs), + + on_item_mb_left: None, + on_item_mb_double: None, + on_item_mb_mid: None, + on_item_mb_right: None, + item_context_builder: Box::new(|_| None), + + on_category_mb_left: None, + on_category_mb_double: None, + on_category_mb_mid: None, + on_category_mb_right: None, + category_context_builder: Box::new(|_| None), + } + } + + pub fn on_item_left_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_left = Some(Box::new(on_click)); + self + } + + pub fn on_item_double_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_double = Some(Box::new(on_click)); + self + } + + pub fn on_item_middle_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_mid = Some(Box::new(on_click)); + self + } + + pub fn on_item_right_click(mut self, on_click: F) -> Self + where + F: Fn(Entity) -> Message + 'static, + { + self.on_item_mb_right = Some(Box::new(on_click)); + self + } + + pub fn item_context(mut self, context_menu_builder: F) -> Self + where + F: Fn(&Item) -> Option>> + 'static, + Message: 'static, + { + self.item_context_builder = Box::new(context_menu_builder); + self + } + + pub fn on_category_left_click(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'static, + { + self.on_category_mb_left = Some(Box::new(on_select)); + self + } + pub fn on_category_double_click(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'static, + { + self.on_category_mb_double = Some(Box::new(on_select)); + self + } + pub fn on_category_middle_click(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'static, + { + self.on_category_mb_mid = Some(Box::new(on_select)); + self + } + + pub fn on_category_right_click(mut self, on_select: F) -> Self + where + F: Fn(Category) -> Message + 'static, + { + self.on_category_mb_right = Some(Box::new(on_select)); + self + } + + pub fn category_context(mut self, context_menu_builder: F) -> Self + where + F: Fn(Category) -> Option>> + 'static, + Message: 'static, + { + self.category_context_builder = Box::new(context_menu_builder); + self + } +} diff --git a/src/widget/text.rs b/src/widget/text.rs index 1aed9500..37e85b80 100644 --- a/src/widget/text.rs +++ b/src/widget/text.rs @@ -7,7 +7,7 @@ use std::borrow::Cow; /// /// [`Text`]: widget::Text pub fn text<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text).font(crate::font::default()) + Text::new(text.into()).font(crate::font::default()) } /// Available presets for text typography @@ -26,72 +26,117 @@ pub enum Typography { /// [`Text`] widget with the Title 1 typography preset. pub fn title1<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(32.0) - .line_height(LineHeight::Absolute(44.0.into())) - .font(crate::font::semibold()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(35.0) + .line_height(LineHeight::Absolute(52.0.into())) + .font(crate::font::semibold()) + } + + inner(text.into()) } /// [`Text`] widget with the Title 2 typography preset. pub fn title2<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(28.0) - .line_height(LineHeight::Absolute(36.0.into())) - .font(crate::font::default()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(29.0) + .line_height(LineHeight::Absolute(43.0.into())) + .font(crate::font::semibold()) + } + + inner(text.into()) } /// [`Text`] widget with the Title 3 typography preset. pub fn title3<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(24.0) - .line_height(LineHeight::Absolute(32.0.into())) - .font(crate::font::default()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(24.0) + .line_height(LineHeight::Absolute(36.0.into())) + .font(crate::font::bold()) + } + + inner(text.into()) } /// [`Text`] widget with the Title 4 typography preset. pub fn title4<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(20.0) - .line_height(LineHeight::Absolute(28.0.into())) - .font(crate::font::default()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(20.0) + .line_height(LineHeight::Absolute(30.0.into())) + .font(crate::font::bold()) + } + + inner(text.into()) } /// [`Text`] widget with the Heading typography preset. pub fn heading<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(14.0) - .line_height(LineHeight::Absolute(iced::Pixels(20.0))) - .font(crate::font::semibold()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(14.0) + .line_height(LineHeight::Absolute(iced::Pixels(21.0))) + .font(crate::font::bold()) + } + + inner(text.into()) } /// [`Text`] widget with the Caption Heading typography preset. pub fn caption_heading<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(10.0) - .line_height(LineHeight::Absolute(iced::Pixels(14.0))) - .font(crate::font::semibold()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(12.0) + .line_height(LineHeight::Absolute(iced::Pixels(17.0))) + .font(crate::font::semibold()) + } + + inner(text.into()) } /// [`Text`] widget with the Body typography preset. pub fn body<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(14.0) - .line_height(LineHeight::Absolute(20.0.into())) - .font(crate::font::default()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(14.0) + .line_height(LineHeight::Absolute(21.0.into())) + .font(crate::font::default()) + } + + inner(text.into()) } /// [`Text`] widget with the Caption typography preset. pub fn caption<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(10.0) - .line_height(LineHeight::Absolute(14.0.into())) - .font(crate::font::default()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(12.0) + .line_height(LineHeight::Absolute(17.0.into())) + .font(crate::font::default()) + } + + inner(text.into()) } /// [`Text`] widget with the Monotext typography preset. pub fn monotext<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text) - .size(14.0) - .line_height(LineHeight::Absolute(20.0.into())) - .font(crate::font::mono()) + #[inline(never)] + fn inner(text: Cow) -> Text { + Text::new(text) + .size(14.0) + .line_height(LineHeight::Absolute(20.0.into())) + .font(crate::font::mono()) + } + + inner(text.into()) } diff --git a/src/widget/text_input/cursor.rs b/src/widget/text_input/cursor.rs index a42596e1..3ffb535c 100644 --- a/src/widget/text_input/cursor.rs +++ b/src/widget/text_input/cursor.rs @@ -3,16 +3,19 @@ // SPDX-License-Identifier: MIT //! Track the cursor of a text input. +use iced_core::text::Affinity; + use super::value::Value; /// The cursor of a text input. -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct Cursor { state: State, + affinity: Affinity, } /// The state of a [`Cursor`]. -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum State { /// Cursor without a selection Index(usize), @@ -27,9 +30,11 @@ pub enum State { } impl Default for Cursor { + #[inline] fn default() -> Self { Self { state: State::Index(0), + affinity: Affinity::Before, } } } @@ -37,6 +42,7 @@ impl Default for Cursor { impl Cursor { /// Returns the [`State`] of the [`Cursor`]. #[must_use] + #[inline(never)] pub fn state(&self, value: &Value) -> State { match self.state { State::Index(index) => State::Index(index.min(value.len())), @@ -57,6 +63,7 @@ impl Cursor { /// /// `start` is guaranteed to be <= than `end`. #[must_use] + #[inline] pub fn selection(&self, value: &Value) -> Option<(usize, usize)> { match self.state(value) { State::Selection { start, end } => Some((start.min(end), start.max(end))), @@ -64,18 +71,22 @@ impl Cursor { } } + #[inline] pub(crate) fn move_to(&mut self, position: usize) { self.state = State::Index(position); } + #[inline] pub(crate) fn move_right(&mut self, value: &Value) { self.move_right_by_amount(value, 1); } + #[inline] pub(crate) fn move_right_by_words(&mut self, value: &Value) { self.move_to(value.next_end_of_word(self.right(value))); } + #[inline] pub(crate) fn move_right_by_amount(&mut self, value: &Value, amount: usize) { match self.state(value) { State::Index(index) => self.move_to(index.saturating_add(amount).min(value.len())), @@ -83,6 +94,7 @@ impl Cursor { } } + #[inline] pub(crate) fn move_left(&mut self, value: &Value) { match self.state(value) { State::Index(index) if index > 0 => self.move_to(index - 1), @@ -91,18 +103,21 @@ impl Cursor { } } + #[inline] pub(crate) fn move_left_by_words(&mut self, value: &Value) { self.move_to(value.previous_start_of_word(self.left(value))); } + #[inline] pub(crate) fn select_range(&mut self, start: usize, end: usize) { - if start == end { - self.state = State::Index(start); + self.state = if start == end { + State::Index(start) } else { - self.state = State::Selection { start, end }; - } + State::Selection { start, end } + }; } + #[inline] pub(crate) fn select_left(&mut self, value: &Value) { match self.state(value) { State::Index(index) if index > 0 => self.select_range(index, index - 1), @@ -111,6 +126,7 @@ impl Cursor { } } + #[inline] pub(crate) fn select_right(&mut self, value: &Value) { match self.state(value) { State::Index(index) if index < value.len() => self.select_range(index, index + 1), @@ -121,6 +137,7 @@ impl Cursor { } } + #[inline] pub(crate) fn select_left_by_words(&mut self, value: &Value) { match self.state(value) { State::Index(index) => self.select_range(index, value.previous_start_of_word(index)), @@ -130,6 +147,7 @@ impl Cursor { } } + #[inline] pub(crate) fn select_right_by_words(&mut self, value: &Value) { match self.state(value) { State::Index(index) => self.select_range(index, value.next_end_of_word(index)), @@ -139,10 +157,12 @@ impl Cursor { } } + #[inline] pub(crate) fn select_all(&mut self, value: &Value) { self.select_range(0, value.len()); } + #[inline] pub(crate) fn start(&self, value: &Value) -> usize { let start = match self.state { State::Index(index) => index, @@ -152,6 +172,7 @@ impl Cursor { start.min(value.len()) } + #[inline] pub(crate) fn end(&self, value: &Value) -> usize { let end = match self.state { State::Index(index) => index, @@ -161,6 +182,7 @@ impl Cursor { end.min(value.len()) } + #[inline] fn left(&self, value: &Value) -> usize { match self.state(value) { State::Index(index) => index, @@ -168,10 +190,44 @@ impl Cursor { } } + #[inline] fn right(&self, value: &Value) -> usize { match self.state(value) { State::Index(index) => index, State::Selection { start, end } => start.max(end), } } + + /// Returns the current cursor [`Affinity`]. + #[must_use] + pub fn affinity(&self) -> Affinity { + self.affinity + } + + /// Sets the cursor [`Affinity`]. + pub fn set_affinity(&mut self, affinity: Affinity) { + self.affinity = affinity; + } + + /// Moves the cursor in a visual direction, accounting for RTL text. + /// + /// `forward` = `true` is visually rightward. + pub fn move_visual(&mut self, forward: bool, by_words: bool, rtl: bool, value: &Value) { + match (forward ^ rtl, by_words) { + (true, false) => self.move_right(value), + (true, true) => self.move_right_by_words(value), + (false, false) => self.move_left(value), + (false, true) => self.move_left_by_words(value), + } + } + + /// Extends the selection in a visual direction, accounting for RTL text. + pub fn select_visual(&mut self, forward: bool, by_words: bool, rtl: bool, value: &Value) { + match (forward ^ rtl, by_words) { + (true, false) => self.select_right(value), + (true, true) => self.select_right_by_words(value), + (false, false) => self.select_left(value), + (false, true) => self.select_left_by_words(value), + } + } } diff --git a/src/widget/text_input/editor.rs b/src/widget/text_input/editor.rs index 301820f4..b8144761 100644 --- a/src/widget/text_input/editor.rs +++ b/src/widget/text_input/editor.rs @@ -10,11 +10,13 @@ pub struct Editor<'a> { } impl<'a> Editor<'a> { + #[inline] pub fn new(value: &'a mut Value, cursor: &'a mut Cursor) -> Editor<'a> { Editor { value, cursor } } #[must_use] + #[inline] pub fn contents(&self) -> String { self.value.to_string() } diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index 90a0f576..4336c757 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -19,35 +19,28 @@ pub use super::value::Value; use apply::Apply; use iced::Limits; +use iced::clipboard::dnd::{DndAction, DndEvent, OfferEvent, SourceEvent}; +use iced::clipboard::mime::AsMimeTypes; use iced_core::event::{self, Event}; +use iced_core::input_method::{self, InputMethod, Preedit}; use iced_core::mouse::{self, click}; use iced_core::overlay::Group; use iced_core::renderer::{self, Renderer as CoreRenderer}; -use iced_core::text::{self, Paragraph, Renderer, Text}; +use iced_core::text::{self, Affinity, Paragraph, Renderer, Text}; use iced_core::time::{Duration, Instant}; use iced_core::touch; +use iced_core::widget::Id; use iced_core::widget::operation::{self, Operation}; use iced_core::widget::tree::{self, Tree}; -use iced_core::widget::Id; use iced_core::window; -use iced_core::{alignment, Background}; -use iced_core::{keyboard, Border, Shadow}; -use iced_core::{layout, overlay}; +use iced_core::{Background, alignment}; +use iced_core::{Border, Shadow, keyboard}; use iced_core::{ Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size, Vector, Widget, }; -#[cfg(feature = "wayland")] -use iced_renderer::core::event::{wayland, PlatformSpecific}; -use iced_renderer::core::widget::OperationOutputWrapper; -#[cfg(feature = "wayland")] -use iced_runtime::command::platform_specific; -use iced_runtime::Command; - -#[cfg(feature = "wayland")] -use cctk::sctk::reexports::client::protocol::wl_data_device_manager::DndAction; -#[cfg(feature = "wayland")] -use iced_runtime::command::platform_specific::wayland::data_device::{DataFromMimeType, DndIcon}; +use iced_core::{layout, overlay}; +use iced_runtime::{Action, Task, task}; thread_local! { // Prevents two inputs from being focused at the same time. @@ -67,25 +60,27 @@ where TextInput::new(placeholder, value) } -/// A text label whiich can transform into a text input on activation. +/// A text label which can transform into a text input on activation. pub fn editable_input<'a, Message: Clone + 'static>( placeholder: impl Into>, text: impl Into>, editing: bool, on_toggle_edit: impl Fn(bool) -> Message + 'a, ) -> TextInput<'a, Message> { - let icon = crate::widget::icon::from_name(if editing { - "edit-clear-symbolic" - } else { - "edit-symbolic" - }); - + // The trailing icon is a placeholder; diff() rebuilds it reactively + // based on the current is_read_only state and value content. TextInput::new(placeholder, text) .style(crate::theme::TextInput::EditableText) .editable() .editing(editing) .on_toggle_edit(on_toggle_edit) - .trailing_icon(icon.size(16).into()) + .trailing_icon( + crate::widget::icon::from_name("edit-symbolic") + .size(16) + .apply(crate::widget::container) + .padding(8) + .into(), + ) } /// Creates a new search [`TextInput`]. @@ -146,7 +141,7 @@ where }) .size(16) .apply(crate::widget::button::custom) - .style(crate::theme::Button::Icon) + .class(crate::theme::Button::Icon) .on_press(msg) .padding(8) .into(), @@ -173,7 +168,6 @@ where .padding(spacing) } -#[cfg(feature = "wayland")] pub(crate) const SUPPORTED_TEXT_MIME_TYPES: &[&str; 6] = &[ "text/plain;charset=utf-8", "text/plain;charset=UTF-8", @@ -182,45 +176,46 @@ pub(crate) const SUPPORTED_TEXT_MIME_TYPES: &[&str; 6] = &[ "text/plain", "TEXT", ]; -#[cfg(feature = "wayland")] -pub type DnDCommand = - Box platform_specific::wayland::data_device::ActionInner>; -#[cfg(not(feature = "wayland"))] -pub type DnDCommand = (); /// A field that can be filled with text. #[allow(missing_debug_implementations)] #[must_use] pub struct TextInput<'a, Message> { - id: Option, + id: Id, placeholder: Cow<'a, str>, value: Value, is_secure: bool, - is_editable: bool, + is_editable_variant: bool, is_read_only: bool, select_on_focus: bool, + double_click_select_delimiter: Option, font: Option<::Font>, width: Length, padding: Padding, size: Option, helper_size: f32, - label: Option<&'a str>, - helper_text: Option<&'a str>, - error: Option<&'a str>, + label: Option>, + helper_text: Option>, + error: Option>, + on_focus: Option, + on_unfocus: Option, on_input: Option Message + 'a>>, on_paste: Option Message + 'a>>, - on_submit: Option, + on_tab: Option, + on_submit: Option Message + 'a>>, on_toggle_edit: Option Message + 'a>>, leading_icon: Option>, trailing_icon: Option>, style: ::Style, on_create_dnd_source: Option Message + 'a>>, - on_dnd_command_produced: Option Message + 'a>>, surface_ids: Option<(window::Id, window::Id)>, dnd_icon: bool, line_height: text::LineHeight, helper_line_height: text::LineHeight, always_active: bool, + /// The text input tracks and manages the input value in its state. + manage_value: bool, + drag_threshold: f32, } impl<'a, Message> TextInput<'a, Message> @@ -237,28 +232,31 @@ where let v: Cow<'a, str> = value.into(); TextInput { - id: None, + id: Id::unique(), placeholder: placeholder.into(), value: Value::new(v.as_ref()), is_secure: false, - is_editable: false, + is_editable_variant: false, is_read_only: false, select_on_focus: false, + double_click_select_delimiter: None, font: None, width: Length::Fill, padding: spacing.into(), size: None, helper_size: 10.0, helper_line_height: text::LineHeight::Absolute(14.0.into()), + on_focus: None, + on_unfocus: None, on_input: None, on_paste: None, on_submit: None, + on_tab: None, on_toggle_edit: None, leading_icon: None, trailing_icon: None, error: None, style: crate::theme::TextInput::default(), - on_dnd_command_produced: None, on_create_dnd_source: None, surface_ids: None, dnd_icon: false, @@ -266,37 +264,51 @@ where label: None, helper_text: None, always_active: false, + manage_value: false, + drag_threshold: 20.0, + } + } + + #[inline] + fn dnd_id(&self) -> u128 { + match &self.id.0 { + iced_core::id::Internal::Custom(id, _) | iced_core::id::Internal::Unique(id) => { + *id as u128 + } + _ => unreachable!(), } } /// Sets the input to be always active. /// This makes it behave as if it was always focused. - pub fn always_active(mut self) -> Self { + #[inline] + pub const fn always_active(mut self) -> Self { self.always_active = true; self } /// Sets the text of the [`TextInput`]. - pub fn label(mut self, label: &'a str) -> Self { - self.label = Some(label); + pub fn label(mut self, label: impl Into>) -> Self { + self.label = Some(label.into()); self } /// Sets the helper text of the [`TextInput`]. - pub fn helper_text(mut self, helper_text: &'a str) -> Self { - self.helper_text = Some(helper_text); + pub fn helper_text(mut self, helper_text: impl Into>) -> Self { + self.helper_text = Some(helper_text.into()); self } /// Sets the [`Id`] of the [`TextInput`]. + #[inline] pub fn id(mut self, id: Id) -> Self { - self.id = Some(id); + self.id = id; self } /// Sets the error message of the [`TextInput`]. - pub fn error(mut self, error: &'a str) -> Self { - self.error = Some(error); + pub fn error(mut self, error: impl Into>) -> Self { + self.error = Some(error.into()); self } @@ -307,56 +319,96 @@ where } /// Converts the [`TextInput`] into a secure password input. - pub fn password(mut self) -> Self { + #[inline] + pub const fn password(mut self) -> Self { self.is_secure = true; self } - pub fn editable(mut self) -> Self { - self.is_editable = true; + /// Applies behaviors unique to the `editable_input` variable. + #[inline] + pub(crate) const fn editable(mut self) -> Self { + self.is_editable_variant = true; self } - pub fn editing(mut self, enable: bool) -> Self { + #[inline] + pub const fn editing(mut self, enable: bool) -> Self { self.is_read_only = !enable; self } /// Selects all text when the text input is focused - pub fn select_on_focus(mut self, select_on_focus: bool) -> Self { + #[inline] + pub const fn select_on_focus(mut self, select_on_focus: bool) -> Self { self.select_on_focus = select_on_focus; self } + /// Sets a delimiter character for double-click selection behavior. + /// + /// When set, double-clicking before the last occurrence of this character + /// selects from the start to that character. Double-clicking after the + /// delimiter uses normal word selection. + #[inline] + pub const fn double_click_select_delimiter(mut self, delimiter: char) -> Self { + self.double_click_select_delimiter = Some(delimiter); + self + } + + /// Emits a message when an unfocused text input has been focused by click. + /// + /// This will not trigger if the input was focused externally by the application. + #[inline] + pub fn on_focus(mut self, on_focus: Message) -> Self { + self.on_focus = Some(on_focus); + self + } + + /// Emits a message when a focused text input has been unfocused via the Tab or Esc key. + /// + /// This will not trigger if the input was unfocused externally by the application. + #[inline] + pub fn on_unfocus(mut self, on_unfocus: Message) -> Self { + self.on_unfocus = Some(on_unfocus); + self + } + /// Sets the message that should be produced when some text is typed into /// the [`TextInput`]. /// /// If this method is not called, the [`TextInput`] will be disabled. - pub fn on_input(mut self, callback: F) -> Self - where - F: 'a + Fn(String) -> Message, - { + pub fn on_input(mut self, callback: impl Fn(String) -> Message + 'a) -> Self { self.on_input = Some(Box::new(callback)); self } - /// Sets the message that should be produced when the [`TextInput`] is - /// focused and the enter key is pressed. - pub fn on_submit(self, message: Message) -> Self { - self.on_submit_maybe(Some(message)) - } - - /// Maybe sets the message that should be produced when the [`TextInput`] is - /// focused and the enter key is pressed. - pub fn on_submit_maybe(mut self, message: Option) -> Self { - self.on_submit = message; + /// Emits a message when a focused text input receives the Enter/Return key. + pub fn on_submit(mut self, callback: impl Fn(String) -> Message + 'a) -> Self { + self.on_submit = Some(Box::new(callback)); self } - pub fn on_toggle_edit(mut self, callback: F) -> Self - where - F: 'a + Fn(bool) -> Message, - { + /// Optionally emits a message when a focused text input receives the Enter/Return key. + pub fn on_submit_maybe(self, callback: Option Message + 'a>) -> Self { + if let Some(callback) = callback { + self.on_submit(callback) + } else { + self + } + } + + /// Emits a message when the Tab key has been captured, which prevents focus from changing. + /// + /// If you do no want to capture the Tab key, use [`TextInput::on_unfocus`] instead. + #[inline] + pub fn on_tab(mut self, on_tab: Message) -> Self { + self.on_tab = Some(on_tab); + self + } + + /// Emits a message when the editable state of the input changes. + pub fn on_toggle_edit(mut self, callback: impl Fn(bool) -> Message + 'a) -> Self { self.on_toggle_edit = Some(Box::new(callback)); self } @@ -371,12 +423,17 @@ where /// Sets the [`Font`] of the [`TextInput`]. /// /// [`Font`]: text::Renderer::Font - pub fn font(mut self, font: ::Font) -> Self { + #[inline] + pub const fn font( + mut self, + font: ::Font, + ) -> Self { self.font = Some(font); self } /// Sets the start [`Icon`] of the [`TextInput`]. + #[inline] pub fn leading_icon( mut self, icon: Element<'a, Message, crate::Theme, crate::Renderer>, @@ -386,6 +443,7 @@ where } /// Sets the end [`Icon`] of the [`TextInput`]. + #[inline] pub fn trailing_icon( mut self, icon: Element<'a, Message, crate::Theme, crate::Renderer>, @@ -401,7 +459,7 @@ where } /// Sets the [`Padding`] of the [`TextInput`]. - pub fn padding>(mut self, padding: P) -> Self { + pub fn padding(mut self, padding: impl Into) -> Self { self.padding = padding.into(); self } @@ -418,11 +476,19 @@ where self } + /// Sets the text input to manage its input value or not + #[inline] + pub const fn manage_value(mut self, manage_value: bool) -> Self { + self.manage_value = manage_value; + self + } + /// Draws the [`TextInput`] with the given [`Renderer`], overriding its /// [`Value`] if provided. /// /// [`Renderer`]: text::Renderer #[allow(clippy::too_many_arguments)] + #[inline] pub fn draw( &self, tree: &Tree, @@ -433,10 +499,12 @@ where value: Option<&Value>, style: &renderer::Style, ) { + let text_layout = self.text_layout(layout); draw( renderer, theme, layout, + text_layout, cursor_position, tree, value.unwrap_or(&self.value), @@ -450,9 +518,9 @@ where &self.style, self.dnd_icon, self.line_height, - self.error, - self.label, - self.helper_text, + self.error.as_deref(), + self.label.as_deref(), + self.helper_text.as_deref(), self.helper_size, self.helper_line_height, &layout.bounds(), @@ -461,36 +529,24 @@ where } /// Sets the start dnd handler of the [`TextInput`]. - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", target_os = "linux"))] pub fn on_start_dnd(mut self, on_start_dnd: impl Fn(State) -> Message + 'a) -> Self { self.on_create_dnd_source = Some(Box::new(on_start_dnd)); self } - /// Sets the dnd command produced handler of the [`TextInput`]. - /// Commands should be returned in the update function of the application. - #[cfg(feature = "wayland")] - pub fn on_dnd_command_produced( - mut self, - on_dnd_command_produced: impl Fn( - Box platform_specific::wayland::data_device::ActionInner>, - ) -> Message - + 'a, - ) -> Self { - self.on_dnd_command_produced = Some(Box::new(on_dnd_command_produced)); - self - } - /// Sets the window id of the [`TextInput`] and the window id of the drag icon. /// Both ids are required to be unique. /// This is required for the dnd to work. - pub fn surface_ids(mut self, window_id: (window::Id, window::Id)) -> Self { + #[inline] + pub const fn surface_ids(mut self, window_id: (window::Id, window::Id)) -> Self { self.surface_ids = Some(window_id); self } /// Sets the mode of this [`TextInput`] to be a drag and drop icon. - pub fn dnd_icon(mut self, dnd_icon: bool) -> Self { + #[inline] + pub const fn dnd_icon(mut self, dnd_icon: bool) -> Self { self.dnd_icon = dnd_icon; self } @@ -500,7 +556,7 @@ where crate::widget::icon::from_name("edit-clear-symbolic") .size(16) .apply(crate::widget::button::custom) - .style(crate::theme::Button::Icon) + .class(crate::theme::Button::Icon) .on_press(on_clear) .padding(8) .into(), @@ -519,16 +575,24 @@ where layout.children().next().unwrap() } } + + /// Set the drag threshold. + pub fn drag_threshold(mut self, drag_threshold: f32) -> Self { + self.drag_threshold = drag_threshold; + self + } } -impl<'a, Message> Widget for TextInput<'a, Message> +impl Widget for TextInput<'_, Message> where Message: Clone + 'static, { + #[inline] fn tag(&self) -> tree::Tag { tree::Tag::of::() } + #[inline] fn state(&self) -> tree::State { tree::State::new(State::new( self.is_secure, @@ -541,15 +605,26 @@ where fn diff(&mut self, tree: &mut Tree) { let state = tree.state.downcast_mut::(); + if !self.manage_value || !self.value.is_empty() && state.tracked_value != self.value { + state.tracked_value = self.value.clone(); + } else if self.value.is_empty() { + self.value = state.tracked_value.clone(); + // std::mem::swap(&mut state.tracked_value, &mut self.value); + } + state.double_click_select_delimiter = self.double_click_select_delimiter; // Unfocus text input if it becomes disabled - if self.on_input.is_none() { + if self.on_input.is_none() && !self.manage_value { state.last_click = None; - state.is_focused = None; + state.is_focused = state.is_focused.map(|mut f| { + f.focused = false; + f + }); state.is_pasting = None; state.dragging_state = None; } let old_value = state .value + .raw() .buffer() .lines .iter() @@ -559,53 +634,89 @@ where || old_value != self.value.to_string() || state .label + .raw() .buffer() .lines .iter() .map(|l| l.text()) .collect::() - != self.label.unwrap_or_default() + != self.label.as_deref().unwrap_or_default() || state .helper_text + .raw() .buffer() .lines .iter() .map(|l| l.text()) .collect::() - != self.helper_text.unwrap_or_default() + != self.helper_text.as_deref().unwrap_or_default() { state.is_secure = self.is_secure; state.dirty = true; } - self.is_read_only = state.is_read_only; - - if self.always_active && state.is_focused.is_none() { + if self.always_active && !state.is_focused() { let now = Instant::now(); LAST_FOCUS_UPDATE.with(|x| x.set(now)); state.is_focused = Some(Focus { updated_at: now, now, + focused: true, + needs_update: false, }); } // if the previous state was at the end of the text, keep it there let old_value = Value::new(&old_value); - if state.is_focused.is_some() { - match state.cursor.state(&old_value) { - cursor::State::Index(index) => { - if index == old_value.len() { - state.cursor.move_to(self.value.len()); - } - } - _ => {} - }; + if state.is_focused() + && let cursor::State::Index(index) = state.cursor.state(&old_value) + { + if index == old_value.len() { + state.cursor.move_to(self.value.len()); + } } - if !state.is_focused.as_ref().map_or(false, |f| { - f.updated_at == LAST_FOCUS_UPDATE.with(|f| f.get()) - }) { - state.is_focused = None; + if let Some(f) = state.is_focused.as_ref().filter(|f| f.focused) { + if f.updated_at != LAST_FOCUS_UPDATE.with(|f| f.get()) { + state.unfocus(); + state.emit_unfocus = true; + } + } + + if self.is_editable_variant { + if !state.is_focused() { + // Not yet interacted, use the widget's value + state.is_read_only = self.is_read_only; + } else { + // Already interacted, use the state + self.is_read_only = state.is_read_only; + } + + let editing = !self.is_read_only; + let icon_name = if editing { + if self.value.is_empty() { + "window-close-symbolic" + } else { + "edit-clear-symbolic" + } + } else { + "edit-symbolic" + }; + + self.trailing_icon = Some( + crate::widget::icon::from_name(icon_name) + .size(16) + .apply(crate::widget::container) + .padding(8) + .into(), + ); + } else { + self.is_read_only = state.is_read_only; + } + + // Stop pasting if input becomes disabled + if !self.manage_value && self.on_input.is_none() { + state.is_pasting = None; } let mut children: Vec<_> = self @@ -625,6 +736,7 @@ where .collect() } + #[inline] fn size(&self) -> Size { Size { width: self.width, @@ -633,7 +745,7 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &crate::Renderer, limits: &layout::Limits, @@ -645,24 +757,24 @@ where let size = self.size.unwrap_or_else(|| renderer.default_size().0); - let bounds = limits.max(); - + let bounds = limits.resolve(Length::Shrink, Length::Fill, Size::INFINITE); let value_paragraph = &mut state.value; let v = self.value.to_string(); value_paragraph.update(Text { content: if self.value.is_empty() { - &self.placeholder + self.placeholder.as_ref() } else { &v }, font, bounds, size: iced::Pixels(size), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, line_height: text::LineHeight::default(), shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, }); let Size { width, height } = @@ -677,11 +789,11 @@ where self.width, self.padding, self.size, - self.leading_icon.as_ref(), - self.trailing_icon.as_ref(), + self.leading_icon.as_mut(), + self.trailing_icon.as_mut(), self.line_height, - self.label, - self.helper_text, + self.label.as_deref(), + self.helper_text.as_deref(), self.helper_size, self.helper_line_height, font, @@ -695,17 +807,18 @@ where if state.dirty { state.dirty = false; let value = if self.is_secure { - self.value.secure() + &self.value.secure() } else { - self.value.clone() + &self.value }; replace_paragraph( state, Layout::new(&res), - &value, + value, font, iced::Pixels(size), line_height, + limits, ); } res @@ -713,23 +826,26 @@ where } fn operate( - &self, + &mut self, tree: &mut Tree, - _layout: Layout<'_>, - _renderer: &crate::Renderer, - operation: &mut dyn Operation>, + layout: Layout<'_>, + renderer: &crate::Renderer, + operation: &mut dyn Operation, ) { + operation.container(Some(&self.id), layout.bounds()); let state = tree.state.downcast_mut::(); - operation.focusable(state, self.id.as_ref()); - operation.text_input(state, self.id.as_ref()); + operation.focusable(Some(&self.id), layout.bounds(), state); + operation.text_input(Some(&self.id), layout.bounds(), state); } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &crate::Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { let mut layout_ = Vec::with_capacity(2); if self.leading_icon.is_some() { @@ -752,53 +868,62 @@ where .zip(&mut tree.children) .zip(layout_) .filter_map(|((child, state), layout)| { - child.as_widget_mut().overlay(state, layout, renderer) + child + .as_widget_mut() + .overlay(state, layout, renderer, viewport, translation) }) .collect::>(); (!children.is_empty()).then(|| Group::with_children(children).overlay()) } - fn on_event( + fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor_position: mouse::Cursor, renderer: &crate::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { + ) { let text_layout = self.text_layout(layout); let mut trailing_icon_layout = None; let font = self.font.unwrap_or_else(|| renderer.default_font()); let size = self.size.unwrap_or_else(|| renderer.default_size().0); let line_height = self.line_height; - if self.is_editable { + // Disables editing of the editable variant when clicking outside of, or for tab focus changes. + if self.is_editable_variant { if let Some(ref on_edit) = self.on_toggle_edit { let state = tree.state.downcast_mut::(); - if !state.is_read_only && state.is_focused.is_none() { + if !state.is_read_only && state.is_focused.is_some_and(|f| !f.focused) { state.is_read_only = true; shell.publish((on_edit)(false)); + } else if let Some(f) = state.is_focused.as_mut().filter(|f| f.needs_update) { + // TODO do we want to just move this to on_focus or on_unfocus for all inputs? + f.needs_update = false; + state.is_read_only = true; + shell.publish((on_edit)(f.focused)); } } } - if tree.children.len() > 0 { + // Calculates the layout of the trailing icon button element. + if !tree.children.is_empty() { let index = tree.children.len() - 1; if let (Some(trailing_icon), Some(tree)) = (self.trailing_icon.as_mut(), tree.children.get_mut(index)) { - let children = text_layout.children(); - trailing_icon_layout = Some(children.last().unwrap()); + trailing_icon_layout = Some(text_layout.children().last().unwrap()); - if let Some(trailing_layout) = trailing_icon_layout { - if cursor_position.is_over(trailing_layout.bounds()) { - let res = trailing_icon.as_widget_mut().on_event( + // Enable custom buttons defined on the trailing icon position to be handled. + if !self.is_editable_variant { + if let Some(trailing_layout) = trailing_icon_layout { + let res = trailing_icon.as_widget_mut().update( tree, - event.clone(), + event, trailing_layout, cursor_position, renderer, @@ -807,15 +932,27 @@ where viewport, ); - if res == event::Status::Captured { - return res; + if shell.is_event_captured() { + return; } } } } } + let state = tree.state.downcast_mut::(); + + if let Some(on_unfocus) = self.on_unfocus.as_ref() { + if state.emit_unfocus { + state.emit_unfocus = false; + shell.publish(on_unfocus.clone()); + } + } + + let dnd_id = self.dnd_id(); + let id = Widget::id(self); update( + id, event, text_layout.children().next().unwrap(), trailing_icon_layout, @@ -825,22 +962,39 @@ where &mut self.value, size, font, + self.is_editable_variant, self.is_secure, - self.is_editable, + self.on_focus.as_ref(), + self.on_unfocus.as_ref(), self.on_input.as_deref(), self.on_paste.as_deref(), - &self.on_submit, + self.on_submit.as_deref(), + self.on_tab.as_ref(), self.on_toggle_edit.as_deref(), || tree.state.downcast_mut::(), self.on_create_dnd_source.as_deref(), - self.dnd_icon, - self.on_dnd_command_produced.as_deref(), - self.surface_ids, + dnd_id, line_height, layout, - ) + self.manage_value, + self.drag_threshold, + self.always_active, + ); + + let state = tree.state.downcast_mut::(); + let value = if self.is_secure { + self.value.secure() + } else { + self.value.clone() + }; + state.scroll_offset = offset( + text_layout.children().next().unwrap().bounds(), + &value, + state, + ); } + #[inline] fn draw( &self, tree: &Tree, @@ -851,26 +1005,28 @@ where cursor_position: mouse::Cursor, viewport: &Rectangle, ) { + let text_layout = self.text_layout(layout); draw( renderer, theme, layout, + text_layout, cursor_position, tree, &self.value, &self.placeholder, self.size, self.font, - self.on_input.is_none(), + self.on_input.is_none() && !self.manage_value, self.is_secure, self.leading_icon.as_ref(), self.trailing_icon.as_ref(), &self.style, self.dnd_icon, self.line_height, - self.error, - self.label, - self.helper_text, + self.error.as_deref(), + self.label.as_deref(), + self.helper_text.as_deref(), self.helper_size, self.helper_line_height, viewport, @@ -891,9 +1047,7 @@ where if let (Some(leading_icon), Some(tree)) = (self.leading_icon.as_ref(), state.children.get(index)) { - let mut children = layout.children(); - children.next(); - let leading_icon_layout = children.next().unwrap(); + let leading_icon_layout = layout.children().nth(1).unwrap(); if cursor_position.is_over(leading_icon_layout.bounds()) { return leading_icon.as_widget().mouse_interaction( @@ -907,9 +1061,7 @@ where index += 1; } - if let (Some(trailing_icon), Some(tree)) = - (self.trailing_icon.as_ref(), state.children.get(index)) - { + if self.trailing_icon.is_some() { let mut children = layout.children(); children.next(); // skip if there is no leading icon @@ -919,26 +1071,72 @@ where let trailing_icon_layout = children.next().unwrap(); if cursor_position.is_over(trailing_icon_layout.bounds()) { - return trailing_icon.as_widget().mouse_interaction( - tree, - layout, - cursor_position, - viewport, - renderer, - ); + if self.is_editable_variant { + return mouse::Interaction::Pointer; + } + + if let Some((trailing_icon, tree)) = + self.trailing_icon.as_ref().zip(state.children.get(index)) + { + return trailing_icon.as_widget().mouse_interaction( + tree, + layout, + cursor_position, + viewport, + renderer, + ); + } } } let mut children = layout.children(); let layout = children.next().unwrap(); - mouse_interaction(layout, cursor_position, self.on_input.is_none()) + mouse_interaction( + layout, + cursor_position, + self.on_input.is_none() && !self.manage_value, + ) } + #[inline] fn id(&self) -> Option { - self.id.clone() + Some(self.id.clone()) } + #[inline] fn set_id(&mut self, id: Id) { - self.id = Some(id); + self.id = id; + } + + fn drag_destinations( + &self, + _state: &Tree, + layout: Layout<'_>, + _renderer: &crate::Renderer, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, + ) { + if let Some(input) = layout.children().last() { + let Rectangle { + x, + y, + width, + height, + } = input.bounds(); + dnd_rectangles.push(iced::clipboard::dnd::DndDestinationRectangle { + id: self.dnd_id(), + rectangle: iced::clipboard::dnd::Rectangle { + x: x as f64, + y: y as f64, + width: width as f64, + height: height as f64, + }, + mime_types: SUPPORTED_TEXT_MIME_TYPES + .iter() + .map(|s| Cow::Borrowed(*s)) + .collect(), + actions: DndAction::Move, + preferred: DndAction::Move, + }); + } } } @@ -954,32 +1152,54 @@ where } } -/// Produces a [`Command`] that focuses the [`TextInput`] with the given [`Id`]. -pub fn focus(id: Id) -> Command { - Command::widget(operation::focusable::focus(id)) +/// Produces a [`Task`] that focuses the [`TextInput`] with the given [`Id`]. +pub fn focus(id: Id) -> Task { + task::effect(Action::widget(operation::focusable::focus(id))) } -/// Produces a [`Command`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the /// end. -pub fn move_cursor_to_end(id: Id) -> Command { - Command::widget(operation::text_input::move_cursor_to_end(id)) +pub fn move_cursor_to_end(id: Id) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to_end( + id, + ))) } -/// Produces a [`Command`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the /// front. -pub fn move_cursor_to_front(id: Id) -> Command { - Command::widget(operation::text_input::move_cursor_to_front(id)) +pub fn move_cursor_to_front(id: Id) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to_front( + id, + ))) } -/// Produces a [`Command`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the +/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the /// provided position. -pub fn move_cursor_to(id: Id, position: usize) -> Command { - Command::widget(operation::text_input::move_cursor_to(id, position)) +pub fn move_cursor_to(id: Id, position: usize) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to( + id, position, + ))) } -/// Produces a [`Command`] that selects all the content of the [`TextInput`] with the given [`Id`]. -pub fn select_all(id: Id) -> Command { - Command::widget(operation::text_input::select_all(id)) +/// Produces a [`Task`] that selects all the content of the [`TextInput`] with the given [`Id`]. +pub fn select_all(id: Id) -> Task { + task::effect(Action::widget(operation::text_input::select_all(id))) +} + +/// Produces a [`Task`] that selects a range of the content of the [`TextInput`] with the given +/// [`Id`]. +pub fn select_range(id: Id, start: usize, end: usize) -> Task { + task::effect(Action::widget(operation::text_input::select_range( + id, start, end, + ))) +} + +/// Produces a [`Task`] that selects from the front to the last occurrence of the given character +/// in the [`TextInput`] with the given [`Id`], or selects all if not found. +pub fn select_until_last(id: Id, value: &str, ch: char) -> Task { + let v = Value::new(value); + let end = v.rfind_char(ch).unwrap_or(v.len()); + select_range(id, 0, end) } /// Computes the layout of a [`TextInput`]. @@ -992,8 +1212,8 @@ pub fn layout( width: Length, padding: Padding, size: Option, - leading_icon: Option<&Element<'_, Message, crate::Theme, crate::Renderer>>, - trailing_icon: Option<&Element<'_, Message, crate::Theme, crate::Renderer>>, + leading_icon: Option<&mut Element<'_, Message, crate::Theme, crate::Renderer>>, + trailing_icon: Option<&mut Element<'_, Message, crate::Theme, crate::Renderer>>, line_height: text::LineHeight, label: Option<&str>, helper_text: Option<&str>, @@ -1007,7 +1227,7 @@ pub fn layout( let mut nodes = Vec::with_capacity(3); let text_pos = if let Some(label) = label { - let text_bounds = limits.resolve(width, Length::Shrink, Size::INFINITY); + let text_bounds = limits.resolve(width, Length::Shrink, Size::INFINITE); let state = tree.state.downcast_mut::(); let label_paragraph = &mut state.label; label_paragraph.update(Text { @@ -1015,11 +1235,12 @@ pub fn layout( font, bounds: text_bounds, size: iced::Pixels(size.unwrap_or_else(|| renderer.default_size().0)), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, line_height, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, }); let label_size = label_paragraph.min_bounds(); @@ -1044,7 +1265,7 @@ pub fn layout( let (leading_icon_width, mut leading_icon) = if let Some((icon, tree)) = leading_icon.zip(children.get_mut(c_i)) { let size = icon.as_widget().size(); - let icon_node = icon.as_widget().layout( + let icon_node = icon.as_widget_mut().layout( tree, renderer, &Limits::NONE.width(size.width).height(size.height), @@ -1059,7 +1280,7 @@ pub fn layout( let (trailing_icon_width, mut trailing_icon) = if let Some((icon, tree)) = trailing_icon.zip(children.get_mut(c_i)) { let size = icon.as_widget().size(); - let icon_node = icon.as_widget().layout( + let icon_node = icon.as_widget_mut().layout( tree, renderer, &Limits::NONE.width(size.width).height(size.height), @@ -1072,9 +1293,7 @@ pub fn layout( let text_limits = limits .width(width) .height(line_height.to_absolute(text_size.into())); - - let text_bounds = text_limits.resolve(width, Length::Shrink, Size::INFINITY); - + let text_bounds = text_limits.resolve(Length::Shrink, Length::Shrink, Size::INFINITE); let text_node = layout::Node::new( text_bounds - Size::new(leading_icon_width + trailing_icon_width, 0.0), ) @@ -1126,9 +1345,9 @@ pub fn layout( } else { let limits = limits .width(width) - .height(text_input_height + padding.vertical()) + .height(text_input_height + padding.y()) .shrink(padding); - let text_bounds = limits.resolve(width, Length::Shrink, Size::INFINITY); + let text_bounds = limits.resolve(Length::Shrink, Length::Shrink, Size::INFINITE); let text = layout::Node::new(text_bounds).move_to(Point::new(padding.left, padding.top)); @@ -1146,7 +1365,7 @@ pub fn layout( .width(width) .shrink(padding) .height(helper_text_line_height.to_absolute(helper_text_size.into())); - let text_bounds = limits.resolve(width, Length::Shrink, Size::INFINITY); + let text_bounds = limits.resolve(width, Length::Shrink, Size::INFINITE); let state = tree.state.downcast_mut::(); let helper_text_paragraph = &mut state.helper_text; helper_text_paragraph.update(Text { @@ -1154,11 +1373,12 @@ pub fn layout( font, bounds: text_bounds, size: iced::Pixels(helper_text_size), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, line_height: helper_text_line_height, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, }); let helper_text_size = helper_text_paragraph.min_bounds(); let helper_text_node = layout::Node::new(helper_text_size).translate(helper_pos); @@ -1181,6 +1401,7 @@ pub fn layout( layout::Node::with_children(limits.resolve(width, size.height, size), nodes) } +// TODO: Merge into widget method since iced has done the same. /// Processes an [`Event`] and updates the [`State`] of a [`TextInput`] /// accordingly. #[allow(clippy::too_many_arguments)] @@ -1188,35 +1409,45 @@ pub fn layout( #[allow(clippy::missing_panics_doc)] #[allow(clippy::cast_lossless)] #[allow(clippy::cast_possible_truncation)] -pub fn update<'a, Message>( - event: Event, +pub fn update<'a, Message: Clone + 'static>( + id: Option, + event: &Event, text_layout: Layout<'_>, - trailing_icon_layout: Option>, + edit_button_layout: Option>, cursor: mouse::Cursor, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, value: &mut Value, size: f32, font: ::Font, + is_editable_variant: bool, is_secure: bool, - is_editable: bool, + on_focus: Option<&Message>, + on_unfocus: Option<&Message>, on_input: Option<&dyn Fn(String) -> Message>, on_paste: Option<&dyn Fn(String) -> Message>, - on_submit: &Option, + on_submit: Option<&dyn Fn(String) -> Message>, + on_tab: Option<&Message>, on_toggle_edit: Option<&dyn Fn(bool) -> Message>, state: impl FnOnce() -> &'a mut State, #[allow(unused_variables)] on_start_dnd_source: Option<&dyn Fn(State) -> Message>, - #[allow(unused_variables)] dnd_icon: bool, - #[allow(unused_variables)] on_dnd_command_produced: Option<&dyn Fn(DnDCommand) -> Message>, - #[allow(unused_variables)] surface_ids: Option<(window::Id, window::Id)>, + #[allow(unused_variables)] dnd_id: u128, line_height: text::LineHeight, layout: Layout<'_>, -) -> event::Status -where - Message: Clone, -{ + manage_value: bool, + drag_threshold: f32, + always_active: bool, +) { let update_cache = |state, value| { - replace_paragraph(state, layout, value, font, iced::Pixels(size), line_height); + replace_paragraph( + state, + layout, + value, + font, + iced::Pixels(size), + line_height, + &Limits::NONE.max_width(text_layout.bounds().width), + ); }; let mut secured_value = if is_secure { @@ -1227,150 +1458,158 @@ where let unsecured_value = value; let value = &mut secured_value; + // NOTE: Clicks must be captured to prevent mouse areas behind them handling the same clicks. + + /// Mark a branch as cold + #[inline] + #[cold] + fn cold() {} + match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { + cold(); let state = state(); - let click_position = if on_input.is_some() { + let click_position = if on_input.is_some() || manage_value { cursor.position_over(layout.bounds()) } else { None }; - if click_position.is_some() { - state.is_focused = state.is_focused.or_else(|| { - let now = Instant::now(); - LAST_FOCUS_UPDATE.with(|x| x.set(now)); - Some(Focus { - updated_at: now, - now, - }) - }); - } - if let Some(cursor_position) = click_position { - let text_layout = layout.children().next().unwrap(); - let target = cursor_position.x - text_layout.bounds().x; + // Check if the edit button was clicked. + if state.dragging_state.is_none() + && edit_button_layout.is_some_and(|l| cursor.is_over(l.bounds())) + { + if is_editable_variant { + let has_content = !unsecured_value.is_empty(); + let is_editing = !state.is_read_only; - let click = mouse::Click::new(cursor_position, state.last_click); + if is_editing && has_content { + if let Some(on_input) = on_input { + shell.publish((on_input)(String::new())); + } + + if manage_value { + *unsecured_value = Value::new(""); + state.tracked_value = unsecured_value.clone(); + + let cleared_value = if is_secure { + unsecured_value.secure() + } else { + unsecured_value.clone() + }; + + update_cache(state, &cleared_value); + } + + state.move_cursor_to_end(); + } else if is_editing { + // Close: toggle back to read-only and unfocus. + state.is_read_only = true; + state.unfocus(); + + if let Some(on_toggle_edit) = on_toggle_edit { + shell.publish(on_toggle_edit(false)); + } + } else { + // Edit: toggle to editing, select all, and focus. + state.is_read_only = false; + state.cursor.select_range(0, value.len()); + + if let Some(on_toggle_edit) = on_toggle_edit { + shell.publish(on_toggle_edit(true)); + } + + let now = Instant::now(); + LAST_FOCUS_UPDATE.with(|x| x.set(now)); + state.is_focused = Some(Focus { + updated_at: now, + now, + focused: true, + needs_update: false, + }); + } + } + + shell.capture_event(); + return; + } + + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + effective_alignment(state.value.raw()), + ); + + cursor_position.x - text_bounds.x - alignment_offset + }; + + let click = + mouse::Click::new(cursor_position, mouse::Button::Left, state.last_click); match ( &state.dragging_state, click.kind(), state.cursor().state(value), ) { - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", target_os = "linux"))] (None, click::Kind::Single, cursor::State::Selection { start, end }) => { - // if something is already selected, we can start a drag and drop for a - // single click that is on top of the selected text - // is the click on selected text? + let left = start.min(end); + let right = end.max(start); - if let ( - Some(on_start_dnd), - Some(on_dnd_command_produced), - Some((window_id, icon_id)), - Some(on_input), - ) = ( - on_start_dnd_source, - on_dnd_command_produced, - surface_ids, - on_input, - ) { - let left = start.min(end); - let right = end.max(start); + let (left_position, _left_offset) = measure_cursor_and_scroll_offset( + state.value.raw(), + text_layout.bounds(), + left, + value, + state.cursor.affinity(), + state.scroll_offset, + ); - let (left_position, _left_offset) = measure_cursor_and_scroll_offset( - &state.value, - text_layout.bounds(), - left, - ); + let (right_position, _right_offset) = measure_cursor_and_scroll_offset( + state.value.raw(), + text_layout.bounds(), + right, + value, + state.cursor.affinity(), + state.scroll_offset, + ); - let (right_position, _right_offset) = measure_cursor_and_scroll_offset( - &state.value, - text_layout.bounds(), - right, - ); - - let width = right_position - left_position; - let selection_bounds = Rectangle { - x: text_layout.bounds().x + left_position, - y: text_layout.bounds().y, - width, - height: text_layout.bounds().height, - }; - - if cursor.is_over(selection_bounds) { - // XXX never start a dnd if the input is secure - if is_secure { - return event::Status::Ignored; - } - let text = - state.selected_text(&value.to_string()).unwrap_or_default(); - state.dragging_state = - Some(DraggingState::Dnd(DndAction::empty(), text.clone())); - let mut editor = Editor::new(unsecured_value, &mut state.cursor); - editor.delete(); - - let contents = editor.contents(); - let unsecured_value = Value::new(&contents); - let message = (on_input)(contents); - shell.publish(message); - shell.publish(on_start_dnd(state.clone())); - let state_clone = state.clone(); - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::StartDnd { - mime_types: SUPPORTED_TEXT_MIME_TYPES - .iter() - .map(std::string::ToString::to_string) - .collect(), - actions: DndAction::Move, - origin_id: window_id, - icon_id: Some(( - DndIcon::Widget(icon_id, Box::new(state_clone.clone())), - iced::Vector::ZERO, - )), - data: Box::new(TextInputString(text.clone())), - } - }))); - - update_cache(state, &unsecured_value); - } else { - update_cache(state, value); - // existing logic for setting the selection - let position = if target > 0.0 { - find_cursor_position(text_layout.bounds(), value, state, target) - } else { - None - }; - - state.cursor.move_to(position.unwrap_or(0)); - state.dragging_state = Some(DraggingState::Selection); - } - } else { - // existing logic for setting the selection - let position = if target > 0.0 { - update_cache(state, value); - find_cursor_position(text_layout.bounds(), value, state, target) - } else { - None - }; - - state.cursor.move_to(position.unwrap_or(0)); - state.dragging_state = Some(DraggingState::Selection); - } - } - (None, click::Kind::Single, _) => { - // existing logic for setting the selection - let position = if target > 0.0 { - update_cache(state, value); - find_cursor_position(text_layout.bounds(), value, state, target) - } else { - None + let selection_start = left_position.min(right_position); + let width = (right_position - left_position).abs(); + let alignment_offset = alignment_offset( + text_layout.bounds().width, + state.value.raw().min_width(), + effective_alignment(state.value.raw()), + ); + let selection_bounds = Rectangle { + x: text_layout.bounds().x + alignment_offset + selection_start + - state.scroll_offset, + y: text_layout.bounds().y, + width, + height: text_layout.bounds().height, }; - state.cursor.move_to(position.unwrap_or(0)); - state.dragging_state = Some(DraggingState::Selection); + if cursor.is_over(selection_bounds) && (on_input.is_some() || manage_value) + { + state.dragging_state = Some(DraggingState::PrepareDnd(cursor_position)); + shell.capture_event(); + return; + } + // clear selection and place cursor at click position + update_cache(state, value); + state.setting_selection(value, text_layout.bounds(), target); + state.dragging_state = None; + shell.capture_event(); + return; + } + (None, click::Kind::Single, _) => { + state.setting_selection(value, text_layout.bounds(), target); } (None | Some(DraggingState::Selection), click::Kind::Double, _) => { update_cache(state, value); @@ -1378,14 +1617,28 @@ where if is_secure { state.cursor.select_all(value); } else { - let position = + let (position, affinity) = find_cursor_position(text_layout.bounds(), value, state, target) - .unwrap_or(0); + .unwrap_or((0, text::Affinity::Before)); - state.cursor.select_range( - value.previous_start_of_word(position), - value.next_end_of_word(position), - ); + state.cursor.set_affinity(affinity); + + if let Some(delimiter) = state.double_click_select_delimiter { + if let Some(delim_pos) = value.rfind_char(delimiter) { + if position <= delim_pos { + state.cursor.select_range(0, delim_pos); + } else { + state.cursor.select_range(delim_pos + 1, value.len()); + } + } else { + state.cursor.select_all(value); + } + } else { + state.cursor.select_range( + value.previous_start_of_word(position), + value.next_end_of_word(position), + ); + } } state.dragging_state = Some(DraggingState::Selection); } @@ -1399,126 +1652,313 @@ where } } - // Enable write mode when an editable input label is clicked - if is_editable - && state.is_read_only - && matches!(state.dragging_state, None | Some(DraggingState::Selection)) + // Focus on click of the text input, and ensure that the input is writable. + if matches!(state.dragging_state, None | Some(DraggingState::Selection)) + && (!state.is_focused() || (is_editable_variant && state.is_read_only)) { - state.is_read_only = false; - if let Some(on_toggle_edit) = on_toggle_edit { - let message = (on_toggle_edit)(!state.is_read_only); - shell.publish(message); - - let now = Instant::now(); - LAST_FOCUS_UPDATE.with(|x| x.set(now)); - state.is_focused = Some(Focus { - updated_at: now, - now, - }); - - state.move_cursor_to_end(); - return event::Status::Captured; + if !state.is_focused() { + if let Some(on_focus) = on_focus { + shell.publish(on_focus.clone()); + } } + + if state.is_read_only { + state.is_read_only = false; + state.cursor.select_range(0, value.len()); + if let Some(on_toggle_edit) = on_toggle_edit { + let message = (on_toggle_edit)(true); + shell.publish(message); + } + } + + let now = Instant::now(); + LAST_FOCUS_UPDATE.with(|x| x.set(now)); + + state.is_focused = Some(Focus { + updated_at: now, + now, + focused: true, + needs_update: false, + }); } state.last_click = Some(click); - return event::Status::Captured; - } - let mut is_trailing_clicked = false; - if is_editable { - if let Some(trailing_layout) = trailing_icon_layout { - is_trailing_clicked = cursor.is_over(trailing_layout.bounds()); + shell.capture_event(); + return; + } else { + state.unfocus(); - if is_trailing_clicked && on_toggle_edit.is_some() { - let Some(pos) = cursor.position() else { - return event::Status::Ignored; - }; - - let click = mouse::Click::new(pos, state.last_click); - - match ( - &state.dragging_state, - click.kind(), - state.cursor().state(value), - ) { - (None, click::Kind::Single, _) => { - state.is_read_only = !state.is_read_only; - if let Some(on_toggle_edit) = on_toggle_edit { - let message = (on_toggle_edit)(!state.is_read_only); - shell.publish(message); - - let now = Instant::now(); - LAST_FOCUS_UPDATE.with(|x| x.set(now)); - state.is_focused = Some(Focus { - updated_at: now, - now, - }); - - state.move_cursor_to_end(); - - return event::Status::Captured; - } - } - _ => { - state.dragging_state = None; - } - } - } + if let Some(on_unfocus) = on_unfocus { + shell.publish(on_unfocus.clone()); } } - - if !is_trailing_clicked && click_position.is_none() { - state.is_focused = None; - state.dragging_state = None; - state.is_pasting = None; - - state.keyboard_modifiers = keyboard::Modifiers::default(); - } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => { + cold(); let state = state(); + #[cfg(all(feature = "wayland", target_os = "linux"))] + if matches!(state.dragging_state, Some(DraggingState::PrepareDnd(_))) { + // clear selection and place cursor at click position + update_cache(state, value); + if let Some(position) = cursor.position_over(layout.bounds()) { + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + effective_alignment(state.value.raw()), + ); + + position.x - text_bounds.x - alignment_offset + }; + state.setting_selection(value, text_layout.bounds(), target); + } + } state.dragging_state = None; + if cursor.is_over(layout.bounds()) { + shell.capture_event(); + } + return; } Event::Mouse(mouse::Event::CursorMoved { position }) | Event::Touch(touch::Event::FingerMoved { position, .. }) => { let state = state(); if matches!(state.dragging_state, Some(DraggingState::Selection)) { - let target = position.x - text_layout.bounds().x; + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + effective_alignment(state.value.raw()), + ); + + position.x - text_bounds.x - alignment_offset + }; update_cache(state, value); - let position = - find_cursor_position(text_layout.bounds(), value, state, target).unwrap_or(0); + let (position, affinity) = + find_cursor_position(text_layout.bounds(), value, state, target) + .unwrap_or((0, text::Affinity::Before)); + state.cursor.set_affinity(affinity); state .cursor .select_range(state.cursor.start(value), position); - return event::Status::Captured; + shell.capture_event(); + return; } - } - Event::Keyboard(keyboard::Event::KeyPressed { key, text, .. }) => { - let state = state(); + #[cfg(all(feature = "wayland", target_os = "linux"))] + if let Some(DraggingState::PrepareDnd(start_position)) = state.dragging_state { + let distance = ((position.x - start_position.x).powi(2) + + (position.y - start_position.y).powi(2)) + .sqrt(); - if let Some(focus) = &mut state.is_focused { - let Some(on_input) = on_input else { - return event::Status::Ignored; - }; + if distance >= drag_threshold { + if is_secure { + return; + } - if state.is_read_only { - return event::Status::Ignored; + let input_text = state.selected_text(&value.to_string()).unwrap_or_default(); + state.dragging_state = + Some(DraggingState::Dnd(DndAction::empty(), input_text.clone())); + let mut editor = Editor::new(unsecured_value, &mut state.cursor); + editor.delete(); + + let contents = editor.contents(); + let unsecured_value = Value::new(&contents); + state.tracked_value = unsecured_value.clone(); + if let Some(on_input) = on_input { + let message = (on_input)(contents); + shell.publish(message); + } + if let Some(on_start_dnd) = on_start_dnd_source { + shell.publish(on_start_dnd(state.clone())); + } + let state_clone = state.clone(); + + iced_core::clipboard::start_dnd( + clipboard, + false, + id.map(iced_core::clipboard::DndSource::Widget), + Some(iced_core::clipboard::IconSurface::new( + Element::from( + TextInput::<'static, ()>::new("", input_text.clone()) + .dnd_icon(true), + ), + iced_core::widget::tree::State::new(state_clone), + Vector::ZERO, + )), + Box::new(TextInputString(input_text)), + DndAction::Move, + ); + + update_cache(state, &unsecured_value); + } else { + state.dragging_state = Some(DraggingState::PrepareDnd(start_position)); } + shell.capture_event(); + return; + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key, + text, + physical_key, + modifiers, + .. + }) => { + let state = state(); + state.keyboard_modifiers = *modifiers; + + if let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) { + if state.is_read_only || (!manage_value && on_input.is_none()) { + return; + }; let modifiers = state.keyboard_modifiers; focus.updated_at = Instant::now(); LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at)); - match key { + // Check if Ctrl/Command+A/C/V/X was pressed. + if state.keyboard_modifiers.command() { + match key.to_latin(*physical_key) { + Some('c') => { + if !is_secure { + if let Some((start, end)) = state.cursor.selection(value) { + clipboard.write( + iced_core::clipboard::Kind::Standard, + value.select(start, end).to_string(), + ); + } + } + } + // XXX if we want to allow cutting of secure text, we need to + // update the cache and decide which value to cut + Some('x') => { + if !is_secure { + if let Some((start, end)) = state.cursor.selection(value) { + clipboard.write( + iced_core::clipboard::Kind::Standard, + value.select(start, end).to_string(), + ); + } + + let mut editor = Editor::new(value, &mut state.cursor); + editor.delete(); + let content = editor.contents(); + state.tracked_value = Value::new(&content); + if let Some(on_input) = on_input { + let message = (on_input)(content); + shell.publish(message); + } + } + } + Some('v') => { + let content = if let Some(content) = state.is_pasting.take() { + content + } else { + let content: String = clipboard + .read(iced_core::clipboard::Kind::Standard) + .unwrap_or_default() + .chars() + .filter(|c| !c.is_control()) + .collect(); + + Value::new(&content) + }; + + let mut editor = Editor::new(unsecured_value, &mut state.cursor); + + editor.paste(content.clone()); + + let contents = editor.contents(); + let unsecured_value = Value::new(&contents); + state.tracked_value = unsecured_value.clone(); + + if let Some(on_input) = on_input { + let message = if let Some(paste) = &on_paste { + (paste)(contents) + } else { + (on_input)(contents) + }; + + shell.publish(message); + } + + state.is_pasting = Some(content); + + let value = if is_secure { + unsecured_value.secure() + } else { + unsecured_value + }; + + update_cache(state, &value); + shell.capture_event(); + return; + } + + Some('a') => { + state.cursor.select_all(value); + shell.capture_event(); + return; + } + + _ => {} + } + } + + // Capture keyboard inputs that should be submitted. + if let Some(c) = text + .as_ref() + .and_then(|t| t.chars().next().filter(|c| !c.is_control())) + { + if state.is_read_only || (!manage_value && on_input.is_none()) { + return; + }; + + state.is_pasting = None; + + if !state.keyboard_modifiers.command() && !modifiers.control() { + let mut editor = Editor::new(unsecured_value, &mut state.cursor); + + editor.insert(c); + + let contents = editor.contents(); + let unsecured_value = Value::new(&contents); + state.tracked_value = unsecured_value.clone(); + + if let Some(on_input) = on_input { + let message = (on_input)(contents); + shell.publish(message); + } + + focus.updated_at = Instant::now(); + LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at)); + + let value = if is_secure { + unsecured_value.secure() + } else { + unsecured_value + }; + + update_cache(state, &value); + + shell.capture_event(); + return; + } + } + + match key.as_ref() { keyboard::Key::Named(keyboard::key::Named::Enter) => { - if let Some(on_submit) = on_submit.clone() { - shell.publish(on_submit); + if let Some(on_submit) = on_submit { + shell.publish((on_submit)(unsecured_value.to_string())); } } keyboard::Key::Named(keyboard::key::Named::Backspace) => { @@ -1538,9 +1978,11 @@ where let contents = editor.contents(); let unsecured_value = Value::new(&contents); - let message = (on_input)(editor.contents()); - shell.publish(message); - + state.tracked_value = unsecured_value.clone(); + if let Some(on_input) = on_input { + let message = (on_input)(editor.contents()); + shell.publish(message); + } let value = if is_secure { unsecured_value.secure() } else { @@ -1564,8 +2006,12 @@ where editor.delete(); let contents = editor.contents(); let unsecured_value = Value::new(&contents); - let message = (on_input)(contents); - shell.publish(message); + if let Some(on_input) = on_input { + let message = (on_input)(contents); + state.tracked_value = unsecured_value.clone(); + shell.publish(message); + } + let value = if is_secure { unsecured_value.secure() } else { @@ -1575,29 +2021,23 @@ where update_cache(state, &value); } keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => { - if platform::is_jump_modifier_pressed(modifiers) && !is_secure { - if modifiers.shift() { - state.cursor.select_left_by_words(value); - } else { - state.cursor.move_left_by_words(value); - } - } else if modifiers.shift() { - state.cursor.select_left(value); + let rtl = state.value.raw().is_rtl(0).unwrap_or(false); + let by_words = platform::is_jump_modifier_pressed(modifiers) && !is_secure; + + if modifiers.shift() { + state.cursor.select_visual(false, by_words, rtl, value); } else { - state.cursor.move_left(value); + state.cursor.move_visual(false, by_words, rtl, value); } } keyboard::Key::Named(keyboard::key::Named::ArrowRight) => { - if platform::is_jump_modifier_pressed(modifiers) && !is_secure { - if modifiers.shift() { - state.cursor.select_right_by_words(value); - } else { - state.cursor.move_right_by_words(value); - } - } else if modifiers.shift() { - state.cursor.select_right(value); + let rtl = state.value.raw().is_rtl(0).unwrap_or(false); + let by_words = platform::is_jump_modifier_pressed(modifiers) && !is_secure; + + if modifiers.shift() { + state.cursor.select_visual(true, by_words, rtl, value); } else { - state.cursor.move_right(value); + state.cursor.move_visual(true, by_words, rtl, value); } } keyboard::Key::Named(keyboard::key::Named::Home) => { @@ -1616,130 +2056,48 @@ where state.cursor.move_to(value.len()); } } - keyboard::Key::Character(ref c) - if "c" == c && state.keyboard_modifiers.command() => - { - if !is_secure { - if let Some((start, end)) = state.cursor.selection(value) { - clipboard.write(value.select(start, end).to_string()); - } - } - } - // XXX if we want to allow cutting of secure text, we need to - // update the cache and decide which value to cut - keyboard::Key::Character(c) - if "x" == c && state.keyboard_modifiers.command() => - { - if !is_secure { - if let Some((start, end)) = state.cursor.selection(value) { - clipboard.write(value.select(start, end).to_string()); - } - - let mut editor = Editor::new(value, &mut state.cursor); - editor.delete(); - - let message = (on_input)(editor.contents()); - - shell.publish(message); - } - } - keyboard::Key::Character(c) - if "v" == c && state.keyboard_modifiers.command() => - { - let content = if let Some(content) = state.is_pasting.take() { - content - } else { - let content: String = clipboard - .read() - .unwrap_or_default() - .chars() - .filter(|c| !c.is_control()) - .collect(); - - Value::new(&content) - }; - - let mut editor = Editor::new(unsecured_value, &mut state.cursor); - - editor.paste(content.clone()); - - let contents = editor.contents(); - let unsecured_value = Value::new(&contents); - let message = if let Some(paste) = &on_paste { - (paste)(contents) - } else { - (on_input)(contents) - }; - shell.publish(message); - - state.is_pasting = Some(content); - - let value = if is_secure { - unsecured_value.secure() - } else { - unsecured_value - }; - - update_cache(state, &value); - } - keyboard::Key::Character(c) - if "a" == c && state.keyboard_modifiers.command() => - { - state.cursor.select_all(value); - } keyboard::Key::Named(keyboard::key::Named::Escape) => { - state.is_focused = None; - state.dragging_state = None; - state.is_pasting = None; + state.unfocus(); + state.is_read_only = true; - state.keyboard_modifiers = keyboard::Modifiers::default(); - } - keyboard::Key::Named( - keyboard::key::Named::Tab - | keyboard::key::Named::ArrowUp - | keyboard::key::Named::ArrowDown, - ) => { - return event::Status::Ignored; - } - keyboard::Key::Character(_) - | keyboard::Key::Named(keyboard::key::Named::Space) => { - if state.is_pasting.is_none() - && !state.keyboard_modifiers.command() - && !modifiers.control() - { - let mut editor = Editor::new(unsecured_value, &mut state.cursor); - - editor.insert( - text.unwrap_or_default().chars().next().unwrap_or_default(), - ); - let contents = editor.contents(); - let unsecured_value = Value::new(&contents); - let message = (on_input)(contents); - shell.publish(message); - - focus.updated_at = Instant::now(); - LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at)); - - let value = if is_secure { - unsecured_value.secure() - } else { - unsecured_value - }; - update_cache(state, &value); - - return event::Status::Captured; + if let Some(on_unfocus) = on_unfocus { + shell.publish(on_unfocus.clone()); } } + + keyboard::Key::Named(keyboard::key::Named::Tab) => { + if let Some(on_tab) = on_tab { + // Allow the application to decide how the event is handled. + // This could be to connect the text input to another text input. + // Or to connect the text input to a button. + shell.publish(on_tab.clone()); + } else { + state.is_read_only = true; + + if let Some(on_unfocus) = on_unfocus { + shell.publish(on_unfocus.clone()); + } + + return; + }; + } + + keyboard::Key::Named( + keyboard::key::Named::ArrowUp | keyboard::key::Named::ArrowDown, + ) => { + return; + } _ => {} } - return event::Status::Captured; + shell.capture_event(); + return; } } Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) => { let state = state(); - if state.is_focused.is_some() { + if state.is_focused() { match key { keyboard::Key::Character(c) if "v" == c => { state.is_pasting = None; @@ -1747,201 +2105,213 @@ where keyboard::Key::Named(keyboard::key::Named::Tab) | keyboard::Key::Named(keyboard::key::Named::ArrowUp) | keyboard::Key::Named(keyboard::key::Named::ArrowDown) => { - return event::Status::Ignored; + return; } _ => {} } - return event::Status::Captured; + shell.capture_event(); + return; } } Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { let state = state(); - state.keyboard_modifiers = modifiers; + state.keyboard_modifiers = *modifiers; } - Event::Window(_, window::Event::RedrawRequested(now)) => { + Event::InputMethod(event) => { let state = state(); - if let Some(focus) = &mut state.is_focused { - focus.now = now; + match event { + input_method::Event::Opened | input_method::Event::Closed => { + state.preedit = matches!(event, input_method::Event::Opened) + .then(input_method::Preedit::new); + shell.capture_event(); + return; + } + input_method::Event::Preedit(content, selection) => { + if state.is_focused() { + state.preedit = Some(input_method::Preedit { + content: content.to_owned(), + selection: selection.clone(), + text_size: Some(size.into()), + }); + shell.capture_event(); + return; + } + } + input_method::Event::Commit(text) => { + let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) else { + return; + }; + let Some(on_input) = on_input else { + return; + }; + if state.is_read_only { + return; + } + + focus.updated_at = Instant::now(); + LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at)); + + let mut editor = Editor::new(unsecured_value, &mut state.cursor); + editor.paste(Value::new(&text)); + + let contents = editor.contents(); + let unsecured_value = Value::new(&contents); + let message = if let Some(paste) = &on_paste { + (paste)(contents) + } else { + (on_input)(contents) + }; + shell.publish(message); + + state.is_pasting = None; + let value = if is_secure { + unsecured_value.secure() + } else { + unsecured_value + }; + + update_cache(state, &value); + shell.capture_event(); + return; + } + } + } + Event::Window(window::Event::RedrawRequested(now)) => { + let state = state(); + + if let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) { + focus.now = *now; let millis_until_redraw = CURSOR_BLINK_INTERVAL_MILLIS - - (now - focus.updated_at).as_millis() % CURSOR_BLINK_INTERVAL_MILLIS; - - shell.request_redraw(window::RedrawRequest::At( - now + Duration::from_millis(u64::try_from(millis_until_redraw).unwrap()), + - (*now - focus.updated_at).as_millis() % CURSOR_BLINK_INTERVAL_MILLIS; + shell.request_redraw_at(window::RedrawRequest::At( + now.checked_add(Duration::from_millis(millis_until_redraw as u64)) + .unwrap_or(*now), )); + + shell.request_input_method(&input_method(state, text_layout, unsecured_value)); + } else if always_active { + shell.request_redraw(); } } - #[cfg(feature = "wayland")] - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DataSource( - wayland::DataSourceEvent::DndFinished | wayland::DataSourceEvent::Cancelled, - ))) => { + #[cfg(all(feature = "wayland", target_os = "linux"))] + Event::Dnd(DndEvent::Source(SourceEvent::Finished | SourceEvent::Cancelled)) => { + cold(); let state = state(); if matches!(state.dragging_state, Some(DraggingState::Dnd(..))) { + // TODO: restore value in text input state.dragging_state = None; - return event::Status::Captured; + shell.capture_event(); + return; } } - #[cfg(feature = "wayland")] - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DataSource( - wayland::DataSourceEvent::DndActionAccepted(action), - ))) => { - let state = state(); - if let Some(DraggingState::Dnd(_, text)) = state.dragging_state.as_ref() { - state.dragging_state = Some(DraggingState::Dnd(action, text.clone())); - return event::Status::Captured; - } - } - #[cfg(feature = "wayland")] - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( - wayland::DndOfferEvent::Enter { x, y, mime_types }, - ))) => { - let Some(on_dnd_command_produced) = on_dnd_command_produced else { - return event::Status::Ignored; - }; - + #[cfg(all(feature = "wayland", target_os = "linux"))] + Event::Dnd(DndEvent::Offer( + rectangle, + OfferEvent::Enter { + x, + y, + mime_types, + surface, + }, + )) if *rectangle == Some(dnd_id) => { + cold(); let state = state(); let is_clicked = text_layout.bounds().contains(Point { - x: x as f32, - y: y as f32, + x: *x as f32, + y: *y as f32, }); - if !is_clicked { - state.dnd_offer = DndOfferState::OutsideWidget(mime_types, DndAction::None); - return event::Status::Captured; - } let mut accepted = false; - for m in &mime_types { + for m in mime_types { if SUPPORTED_TEXT_MIME_TYPES.contains(&m.as_str()) { let clone = m.clone(); accepted = true; - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::Accept(Some( - clone.clone(), - )) - }))); } } if accepted { - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::SetActions { - preferred: DndAction::Move, - accepted: DndAction::Move.union(DndAction::Copy), - } - }))); - let target = x as f32 - text_layout.bounds().x; - state.dnd_offer = DndOfferState::HandlingOffer(mime_types.clone(), DndAction::None); - // existing logic for setting the selection - let position = if target > 0.0 { - update_cache(state, value); - find_cursor_position(text_layout.bounds(), value, state, target) - } else { - None - }; + let target = { + let text_bounds = text_layout.bounds(); - state.cursor.move_to(position.unwrap_or(0)); - return event::Status::Captured; + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + effective_alignment(state.value.raw()), + ); + + *x as f32 - text_bounds.x - alignment_offset + }; + state.dnd_offer = + DndOfferState::HandlingOffer(mime_types.clone(), DndAction::empty()); + // existing logic for setting the selection + update_cache(state, value); + let (position, affinity) = + find_cursor_position(text_layout.bounds(), value, state, target) + .unwrap_or((0, text::Affinity::Before)); + + state.cursor.set_affinity(affinity); + state.cursor.move_to(position); + shell.capture_event(); + return; } } - #[cfg(feature = "wayland")] - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( - wayland::DndOfferEvent::Motion { x, y }, - ))) => { - let Some(on_dnd_command_produced) = on_dnd_command_produced else { - return event::Status::Ignored; - }; - + #[cfg(all(feature = "wayland", target_os = "linux"))] + Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Motion { x, y })) + if *rectangle == Some(dnd_id) => + { let state = state(); - let is_clicked = text_layout.bounds().contains(Point { - x: x as f32, - y: y as f32, - }); - if !is_clicked { - if let DndOfferState::HandlingOffer(mime_types, action) = state.dnd_offer.clone() { - state.dnd_offer = DndOfferState::OutsideWidget(mime_types, action); - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::SetActions { - preferred: DndAction::None, - accepted: DndAction::None, - } - }))); - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::Accept(None) - }))); - } - return event::Status::Captured; - } else if let DndOfferState::OutsideWidget(mime_types, action) = state.dnd_offer.clone() - { - let mut accepted = false; - for m in &mime_types { - if SUPPORTED_TEXT_MIME_TYPES.contains(&m.as_str()) { - accepted = true; - let clone = m.clone(); - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::Accept(Some( - clone.clone(), - )) - }))); - } - } - if accepted { - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::SetActions { - preferred: DndAction::Move, - accepted: DndAction::Move.union(DndAction::Copy), - } - }))); - state.dnd_offer = DndOfferState::HandlingOffer(mime_types.clone(), action); - } + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + effective_alignment(state.value.raw()), + ); + + *x as f32 - text_bounds.x - alignment_offset }; - let target = x as f32 - text_layout.bounds().x; // existing logic for setting the selection - let position = if target > 0.0 { - update_cache(state, value); + update_cache(state, value); + let (position, affinity) = find_cursor_position(text_layout.bounds(), value, state, target) - } else { - None - }; + .unwrap_or((0, text::Affinity::Before)); - state.cursor.move_to(position.unwrap_or(0)); - return event::Status::Captured; + state.cursor.set_affinity(affinity); + state.cursor.move_to(position); + shell.capture_event(); + return; } - #[cfg(feature = "wayland")] - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( - wayland::DndOfferEvent::DropPerformed, - ))) => { - let Some(on_dnd_command_produced) = on_dnd_command_produced else { - return event::Status::Ignored; - }; - + #[cfg(all(feature = "wayland", target_os = "linux"))] + Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Drop)) if *rectangle == Some(dnd_id) => { + cold(); let state = state(); if let DndOfferState::HandlingOffer(mime_types, _action) = state.dnd_offer.clone() { let Some(mime_type) = SUPPORTED_TEXT_MIME_TYPES .iter() - .find(|m| mime_types.contains(&(**m).to_string())) + .find(|&&m| mime_types.iter().any(|t| t == m)) else { state.dnd_offer = DndOfferState::None; - return event::Status::Captured; + shell.capture_event(); + return; }; state.dnd_offer = DndOfferState::Dropped; - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::RequestDndData( - (*mime_type).to_string(), - ) - }))); - } else if let DndOfferState::OutsideWidget(..) = &state.dnd_offer { - state.dnd_offer = DndOfferState::None; - return event::Status::Captured; } - return event::Status::Ignored; + + return; } - #[cfg(feature = "wayland")] - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( - wayland::DndOfferEvent::Leave, - ))) => { + #[cfg(all(feature = "wayland", target_os = "linux"))] + Event::Dnd(DndEvent::Offer(id, OfferEvent::LeaveDestination)) if Some(dnd_id) != *id => {} + #[cfg(all(feature = "wayland", target_os = "linux"))] + Event::Dnd(DndEvent::Offer( + rectangle, + OfferEvent::Leave | OfferEvent::LeaveDestination, + )) => { + cold(); let state = state(); // ASHLEY TODO we should be able to reset but for now we don't if we are handling a // drop @@ -1951,24 +2321,24 @@ where state.dnd_offer = DndOfferState::None; } }; - return event::Status::Captured; + shell.capture_event(); + return; } - #[cfg(feature = "wayland")] - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( - wayland::DndOfferEvent::DndData { mime_type, data }, - ))) => { - let Some(on_dnd_command_produced) = on_dnd_command_produced else { - return event::Status::Ignored; - }; - + #[cfg(all(feature = "wayland", target_os = "linux"))] + Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Data { data, mime_type })) + if *rectangle == Some(dnd_id) => + { + cold(); let state = state(); - if let DndOfferState::Dropped = state.dnd_offer.clone() { + if matches!(&state.dnd_offer, DndOfferState::Dropped) { state.dnd_offer = DndOfferState::None; if !SUPPORTED_TEXT_MIME_TYPES.contains(&mime_type.as_str()) || data.is_empty() { - return event::Status::Captured; + shell.capture_event(); + return; } - let Ok(content) = String::from_utf8(data) else { - return event::Status::Captured; + let Ok(content) = String::from_utf8(data.clone()) else { + shell.capture_event(); + return; }; let mut editor = Editor::new(unsecured_value, &mut state.cursor); @@ -1976,49 +2346,61 @@ where editor.paste(Value::new(content.as_str())); let contents = editor.contents(); let unsecured_value = Value::new(&contents); - + state.tracked_value = unsecured_value.clone(); if let Some(on_paste) = on_paste.as_ref() { let message = (on_paste)(contents); shell.publish(message); } - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::DndFinished - }))); let value = if is_secure { unsecured_value.secure() } else { unsecured_value }; update_cache(state, &value); - return event::Status::Captured; + shell.capture_event(); + return; } - return event::Status::Ignored; - } - #[cfg(feature = "wayland")] - Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::DndOffer( - wayland::DndOfferEvent::SourceActions(actions), - ))) => { - let Some(on_dnd_command_produced) = on_dnd_command_produced else { - return event::Status::Ignored; - }; - - let state = state(); - if let DndOfferState::HandlingOffer(..) = state.dnd_offer.clone() { - shell.publish(on_dnd_command_produced(Box::new(move || { - platform_specific::wayland::data_device::ActionInner::SetActions { - preferred: actions.intersection(DndAction::Move), - accepted: actions, - } - }))); - return event::Status::Captured; - } - return event::Status::Ignored; + return; } _ => {} } +} - event::Status::Ignored +fn input_method<'b>( + state: &'b State, + text_layout: Layout<'_>, + value: &Value, +) -> InputMethod<&'b str> { + if !state.is_focused() { + return InputMethod::Disabled; + }; + + let text_bounds = text_layout.bounds(); + let cursor_index = match state.cursor.state(value) { + cursor::State::Index(position) => position, + cursor::State::Selection { start, end } => start.min(end), + }; + let (cursor, offset) = measure_cursor_and_scroll_offset( + state.value.raw(), + text_bounds, + cursor_index, + value, + state.cursor.affinity(), + state.scroll_offset, + ); + InputMethod::Enabled { + cursor: Rectangle::new( + Point::new(text_bounds.x + cursor - offset, text_bounds.y), + Size::new(1.0, text_bounds.height), + ), + purpose: if state.is_secure { + input_method::Purpose::Secure + } else { + input_method::Purpose::Normal + }, + preedit: state.preedit.as_ref().map(input_method::Preedit::as_ref), + } } /// Draws the [`TextInput`] with the given [`Renderer`], overriding its @@ -2032,6 +2414,7 @@ pub fn draw<'a, Message>( renderer: &mut crate::Renderer, theme: &crate::Theme, layout: Layout<'_>, + text_layout: Layout<'_>, cursor_position: mouse::Cursor, tree: &Tree, value: &Value, @@ -2083,7 +2466,8 @@ pub fn draw<'a, Message>( let mut children_layout = layout.children(); let bounds = layout.bounds(); - let text_bounds = children_layout.next().unwrap().bounds(); + // XXX Dnd widget may not have a layout with children, so we just use the text_layout + let text_bounds = children_layout.next().unwrap_or(text_layout).bounds(); let is_mouse_over = cursor_position.is_over(bounds); @@ -2130,6 +2514,7 @@ pub fn draw<'a, Message>( color: Color::TRANSPARENT, blur_radius: 0.0, }, + snap: true, }, appearance.background, ); @@ -2146,6 +2531,7 @@ pub fn draw<'a, Message>( color: Color::TRANSPARENT, blur_radius: 0.0, }, + snap: true, }, Background::Color(Color::TRANSPARENT), ); @@ -2163,6 +2549,7 @@ pub fn draw<'a, Message>( color: Color::TRANSPARENT, blur_radius: 0.0, }, + snap: true, }, appearance.background, ); @@ -2172,15 +2559,16 @@ pub fn draw<'a, Message>( if let (Some(label_layout), Some(label)) = (label_layout, label) { renderer.fill_text( Text { - content: label, + content: label.to_string(), size: iced::Pixels(size.unwrap_or_else(|| renderer.default_size().0)), font: font.unwrap_or_else(|| renderer.default_font()), bounds: label_layout.bounds().size(), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Top, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, line_height, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, }, label_layout.bounds().position(), appearance.label_color, @@ -2190,8 +2578,11 @@ pub fn draw<'a, Message>( let mut child_index = 0; let leading_icon_tree = children.get(child_index); // draw the start icon in the text input + let has_start_icon = icon.is_some(); if let (Some(icon), Some(tree)) = (icon, leading_icon_tree) { - let icon_layout = children_layout.next().unwrap(); + let mut children = text_layout.children(); + let _ = children.next().unwrap(); + let icon_layout = children.next().unwrap(); icon.as_widget().draw( tree, @@ -2212,74 +2603,46 @@ pub fn draw<'a, Message>( let text = value.to_string(); let font = font.unwrap_or_else(|| renderer.default_font()); let size = size.unwrap_or_else(|| renderer.default_size().0); + let text_width = state.value.min_width(); + let actual_width = text_width.max(text_bounds.width); let radius_0 = THEME.lock().unwrap().cosmic().corner_radii.radius_0.into(); - let (cursor, offset) = if let Some(focus) = &state.is_focused { + #[cfg(all(feature = "wayland", target_os = "linux"))] + let handling_dnd_offer = !matches!(state.dnd_offer, DndOfferState::None); + #[cfg(not(all(feature = "wayland", target_os = "linux")))] + let handling_dnd_offer = false; + let (cursors, offset, is_selecting) = if let Some(focus) = + state.is_focused.filter(|f| f.focused).or_else(|| { + let now = Instant::now(); + handling_dnd_offer.then_some(Focus { + needs_update: false, + updated_at: now, + now, + focused: true, + }) + }) { match state.cursor.state(value) { cursor::State::Index(position) => { - let (text_value_width, offset) = - measure_cursor_and_scroll_offset(&state.value, text_bounds, position); + let (text_value_width, _) = measure_cursor_and_scroll_offset( + state.value.raw(), + text_bounds, + position, + value, + state.cursor.affinity(), + state.scroll_offset, + ); + let is_cursor_visible = handling_dnd_offer + || ((focus.now - focus.updated_at).as_millis() / CURSOR_BLINK_INTERVAL_MILLIS) + .is_multiple_of(2); - let is_cursor_visible = - ((focus.now - focus.updated_at).as_millis() / CURSOR_BLINK_INTERVAL_MILLIS) % 2 - == 0; - - if is_cursor_visible { - if dnd_icon { - (None, 0.0) - } else { - ( - Some(( - renderer::Quad { - bounds: Rectangle { - x: text_bounds.x + text_value_width - offset, - y: text_bounds.y, - width: 1.0, - height: text_bounds.height, - }, - border: Border { - width: 0.0, - color: Color::TRANSPARENT, - radius: radius_0, - }, - shadow: Shadow { - offset: Vector::ZERO, - color: Color::TRANSPARENT, - blur_radius: 0.0, - }, - }, - text_color, - )), - offset, - ) - } - } else { - (None, offset) - } - } - cursor::State::Selection { start, end } => { - let left = start.min(end); - let right = end.max(start); - - let value_paragraph = &state.value; - let (left_position, left_offset) = - measure_cursor_and_scroll_offset(value_paragraph, text_bounds, left); - - let (right_position, right_offset) = - measure_cursor_and_scroll_offset(value_paragraph, text_bounds, right); - - let width = right_position - left_position; - - if dnd_icon { - (None, 0.0) - } else { + if is_cursor_visible && !dnd_icon { ( - Some(( + vec![( renderer::Quad { bounds: Rectangle { - x: text_bounds.x + left_position, + x: (text_bounds.x + text_value_width).floor(), y: text_bounds.y, - width, + width: 1.0, height: text_bounds.height, }, border: Border { @@ -2292,35 +2655,105 @@ pub fn draw<'a, Message>( color: Color::TRANSPARENT, blur_radius: 0.0, }, + snap: true, }, - appearance.selected_fill, - )), - if end == right { - right_offset - } else { - left_offset - }, + text_color, + )], + state.scroll_offset, + false, ) + } else { + ( + Vec::<(renderer::Quad, Color)>::new(), + if dnd_icon { 0.0 } else { state.scroll_offset }, + false, + ) + } + } + cursor::State::Selection { start, end } => { + let left = start.min(end); + let right = end.max(start); + + if dnd_icon { + (Vec::<(renderer::Quad, Color)>::new(), 0.0, true) + } else { + let lo_byte = value.byte_index_at_grapheme(left); + let hi_byte = value.byte_index_at_grapheme(right); + + let rects = state.value.raw().highlight( + 0, + (lo_byte, text::Affinity::After), + (hi_byte, text::Affinity::Before), + ); + + let cursors: Vec<(renderer::Quad, Color)> = rects + .into_iter() + .map(|r| { + ( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + r.x, + y: text_bounds.y, + width: r.width, + height: text_bounds.height, + }, + border: Border { + width: 0.0, + color: Color::TRANSPARENT, + radius: radius_0, + }, + shadow: Shadow { + offset: Vector::ZERO, + color: Color::TRANSPARENT, + blur_radius: 0.0, + }, + snap: true, + }, + appearance.selected_fill, + ) + }) + .collect(); + + (cursors, state.scroll_offset, true) } } } } else { - (None, 0.0) + let unfocused_offset = match effective_alignment(state.value.raw()) { + alignment::Horizontal::Right => { + (state.value.raw().min_width() - text_bounds.width).max(0.0) + } + _ => 0.0, + }; + + ( + Vec::<(renderer::Quad, Color)>::new(), + unfocused_offset, + false, + ) }; - let text_width = state.value.min_width(); - let render = |renderer: &mut crate::Renderer| { - if let Some((cursor, color)) = cursor { - renderer.fill_quad(cursor, color); - } else { + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + effective_alignment(state.value.raw()), + ); + + if cursors.is_empty() { renderer.with_translation(Vector::ZERO, |_| {}); + } else { + renderer.with_translation(Vector::new(alignment_offset - offset, 0.0), |renderer| { + for (quad, color) in &cursors { + renderer.fill_quad(*quad, *color); + } + }); } let bounds = Rectangle { - x: text_bounds.x - offset, + x: text_bounds.x + alignment_offset - offset, y: text_bounds.center_y(), - width: f32::INFINITY, + width: actual_width, ..text_bounds }; let color = if text.is_empty() { @@ -2331,29 +2764,41 @@ pub fn draw<'a, Message>( renderer.fill_text( Text { - content: if text.is_empty() { placeholder } else { &text }, + content: if text.is_empty() { + placeholder.to_string() + } else { + text.clone() + }, font, bounds: bounds.size(), size: iced::Pixels(size), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, + align_x: text::Alignment::Default, + align_y: alignment::Vertical::Center, line_height: text::LineHeight::default(), shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, }, bounds.position(), color, - *viewport, + text_bounds, ); }; + // FIXME: we always must clip with a layer because of what appears to be a tiny-skia text clipping issue. + // Otherwise overflowing text escapes the bounds of the input. renderer.with_layer(text_bounds, render); let trailing_icon_tree = children.get(child_index); // draw the end icon in the text input if let (Some(icon), Some(tree)) = (trailing_icon, trailing_icon_tree) { - let icon_layout = children_layout.next().unwrap(); + let mut children = text_layout.children(); + let mut icon_layout = children.next().unwrap(); + if has_start_icon { + icon_layout = children.next().unwrap(); + } + icon_layout = children.next().unwrap(); icon.as_widget().draw( tree, @@ -2374,15 +2819,16 @@ pub fn draw<'a, Message>( if let (Some(helper_text_layout), Some(helper_text)) = (helper_text_layout, helper_text) { renderer.fill_text( Text { - content: helper_text, + content: helper_text.to_string(), // TODO remove to_string? size: iced::Pixels(helper_text_size), font, bounds: helper_text_layout.bounds().size(), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Top, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, line_height: helper_line_height, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, }, helper_text_layout.bounds().position(), text_color, @@ -2413,62 +2859,80 @@ pub fn mouse_interaction( #[derive(Debug, Clone)] pub struct TextInputString(pub String); -#[cfg(feature = "wayland")] -impl DataFromMimeType for TextInputString { - fn from_mime_type(&self, mime_type: &str) -> Option> { - SUPPORTED_TEXT_MIME_TYPES - .contains(&mime_type) - .then(|| self.0.as_bytes().to_vec()) +#[cfg(all(feature = "wayland", target_os = "linux"))] +impl AsMimeTypes for TextInputString { + fn available(&self) -> Cow<'static, [String]> { + Cow::Owned( + SUPPORTED_TEXT_MIME_TYPES + .iter() + .cloned() + .map(String::from) + .collect::>(), + ) + } + + fn as_bytes(&self, mime_type: &str) -> Option> { + if SUPPORTED_TEXT_MIME_TYPES.contains(&mime_type) { + Some(Cow::Owned(self.0.clone().into_bytes())) + } else { + None + } } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum DraggingState { Selection, - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", target_os = "linux"))] + PrepareDnd(Point), + #[cfg(all(feature = "wayland", target_os = "linux"))] Dnd(DndAction, String), } -#[cfg(feature = "wayland")] +#[cfg(all(feature = "wayland", target_os = "linux"))] #[derive(Debug, Default, Clone)] pub(crate) enum DndOfferState { #[default] None, - OutsideWidget(Vec, DndAction), HandlingOffer(Vec, DndAction), Dropped, } #[derive(Debug, Default, Clone)] -#[cfg(not(feature = "wayland"))] +#[cfg(not(all(feature = "wayland", target_os = "linux")))] pub(crate) struct DndOfferState; /// The state of a [`TextInput`]. #[derive(Debug, Default, Clone)] #[must_use] pub struct State { - pub value: crate::Paragraph, - pub placeholder: crate::Paragraph, - pub label: crate::Paragraph, - pub helper_text: crate::Paragraph, + pub tracked_value: Value, + pub value: crate::Plain, + pub placeholder: crate::Plain, + pub label: crate::Plain, + pub helper_text: crate::Plain, pub dirty: bool, pub is_secure: bool, pub is_read_only: bool, + pub emit_unfocus: bool, select_on_focus: bool, + double_click_select_delimiter: Option, is_focused: Option, dragging_state: Option, - #[cfg(feature = "wayland")] dnd_offer: DndOfferState, is_pasting: Option, last_click: Option, cursor: Cursor, + preedit: Option, keyboard_modifiers: keyboard::Modifiers, - // TODO: Add stateful horizontal scrolling offset + scroll_offset: f32, } #[derive(Debug, Clone, Copy)] struct Focus { updated_at: Instant, now: Instant, + focused: bool, + needs_update: bool, } impl State { @@ -2487,6 +2951,8 @@ impl State { Focus { updated_at: now, now, + focused: true, + needs_update: false, } }), select_on_focus, @@ -2508,7 +2974,7 @@ impl State { } } - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", target_os = "linux"))] /// Returns the current value of the dragged text in the [`TextInput`]. #[must_use] pub fn dragged_text(&self) -> Option { @@ -2521,47 +2987,60 @@ impl State { /// Creates a new [`State`], representing a focused [`TextInput`]. pub fn focused(is_secure: bool, is_read_only: bool) -> Self { Self { + tracked_value: Value::default(), is_secure, - value: crate::Paragraph::new(), - placeholder: crate::Paragraph::new(), - label: crate::Paragraph::new(), - helper_text: crate::Paragraph::new(), + value: crate::Plain::default(), + placeholder: crate::Plain::default(), + label: crate::Plain::default(), + helper_text: crate::Plain::default(), is_read_only, + emit_unfocus: false, is_focused: None, select_on_focus: false, + double_click_select_delimiter: None, dragging_state: None, - #[cfg(feature = "wayland")] dnd_offer: DndOfferState::default(), is_pasting: None, last_click: None, cursor: Cursor::default(), + preedit: None, keyboard_modifiers: keyboard::Modifiers::default(), + scroll_offset: 0.0, dirty: false, } } /// Returns whether the [`TextInput`] is currently focused or not. + #[inline] #[must_use] pub fn is_focused(&self) -> bool { - self.is_focused.is_some() + self.is_focused.is_some_and(|f| f.focused) } /// Returns the [`Cursor`] of the [`TextInput`]. + #[inline] #[must_use] pub fn cursor(&self) -> Cursor { self.cursor } /// Focuses the [`TextInput`]. + #[cold] pub fn focus(&mut self) { let now = Instant::now(); LAST_FOCUS_UPDATE.with(|x| x.set(now)); + let was_focused = self.is_focused.is_some_and(|f| f.focused); self.is_read_only = false; self.is_focused = Some(Focus { updated_at: now, now, + focused: true, + needs_update: false, }); + if was_focused { + return; + } if self.select_on_focus { self.select_all() } else { @@ -2570,102 +3049,176 @@ impl State { } /// Unfocuses the [`TextInput`]. - pub fn unfocus(&mut self) { - self.is_focused = None; + #[cold] + pub(super) fn unfocus(&mut self) { + self.move_cursor_to_front(); + self.last_click = None; + self.is_focused = self.is_focused.map(|mut f| { + f.focused = false; + f.needs_update = false; + f + }); + self.dragging_state = None; + self.is_pasting = None; + self.keyboard_modifiers = keyboard::Modifiers::default(); } /// Moves the [`Cursor`] of the [`TextInput`] to the front of the input text. + #[inline] pub fn move_cursor_to_front(&mut self) { self.cursor.move_to(0); } /// Moves the [`Cursor`] of the [`TextInput`] to the end of the input text. + #[inline] pub fn move_cursor_to_end(&mut self) { self.cursor.move_to(usize::MAX); } /// Moves the [`Cursor`] of the [`TextInput`] to an arbitrary location. + #[inline] pub fn move_cursor_to(&mut self, position: usize) { self.cursor.move_to(position); } /// Selects all the content of the [`TextInput`]. + #[inline] pub fn select_all(&mut self) { self.cursor.select_range(0, usize::MAX); } + + /// Selects a range of the content of the [`TextInput`]. + #[inline] + pub fn select_range(&mut self, start: usize, end: usize) { + self.cursor.select_range(start, end); + } + + pub(super) fn setting_selection(&mut self, value: &Value, bounds: Rectangle, target: f32) { + let (position, affinity) = find_cursor_position(bounds, value, self, target) + .unwrap_or((0, text::Affinity::Before)); + + self.cursor.set_affinity(affinity); + self.cursor.move_to(position); + self.dragging_state = Some(DraggingState::Selection); + } } impl operation::Focusable for State { + #[inline] fn is_focused(&self) -> bool { Self::is_focused(self) } + #[inline] fn focus(&mut self) { Self::focus(self); + if let Some(focus) = self.is_focused.as_mut() { + focus.needs_update = true; + } } + #[inline] fn unfocus(&mut self) { Self::unfocus(self); + if let Some(focus) = self.is_focused.as_mut() { + focus.needs_update = true; + } } } impl operation::TextInput for State { + #[inline] fn move_cursor_to_front(&mut self) { Self::move_cursor_to_front(self); } + #[inline] fn move_cursor_to_end(&mut self) { Self::move_cursor_to_end(self); } + #[inline] fn move_cursor_to(&mut self, position: usize) { Self::move_cursor_to(self, position); } + #[inline] fn select_all(&mut self) { Self::select_all(self); } + + fn text(&self) -> &str { + todo!() + } + + #[inline] + fn select_range(&mut self, start: usize, end: usize) { + Self::select_range(self, start, end); + } } +#[inline(never)] fn measure_cursor_and_scroll_offset( paragraph: &impl text::Paragraph, text_bounds: Rectangle, cursor_index: usize, + value: &Value, + affinity: text::Affinity, + current_offset: f32, ) -> (f32, f32) { - let grapheme_position = paragraph - .grapheme_position(0, cursor_index) + let byte_index = value.byte_index_at_grapheme(cursor_index); + let position = paragraph + .cursor_position(0, byte_index, affinity) .unwrap_or(Point::ORIGIN); - let offset = ((grapheme_position.x + 5.0) - text_bounds.width).max(0.0); + // The visible window in paragraph coordinates is: + // [current_offset, current_offset + text_bounds.width] + // Keep the cursor visible with a 5px margin on each side. + let offset = if position.x > current_offset + text_bounds.width - 5.0 { + // Cursor past right edge of visible window → scroll left + (position.x + 5.0) - text_bounds.width + } else if position.x < current_offset + 5.0 { + // Cursor past left edge of visible window → scroll right + position.x - 5.0 + } else { + // Cursor is within visible window → keep current scroll + current_offset + }; - (grapheme_position.x, offset) + let max_offset = (paragraph.min_width() - text_bounds.width).max(0.0); + let offset = offset.clamp(0.0, max_offset); + + (position.x, offset) } /// Computes the position of the text cursor at the given X coordinate of /// a [`TextInput`]. +#[inline(never)] fn find_cursor_position( text_bounds: Rectangle, value: &Value, state: &State, x: f32, -) -> Option { - let offset = offset(text_bounds, value, state); - let value = value.to_string(); +) -> Option<(usize, text::Affinity)> { + let value_str = value.to_string(); - let char_offset = state - .value - .hit_test(Point::new(x + offset, text_bounds.height / 2.0)) - .map(text::Hit::cursor)?; + let hit = state.value.raw().hit_test(Point::new( + x + state.scroll_offset, + text_bounds.height / 2.0, + ))?; + let char_offset = hit.cursor(); + let affinity = hit.affinity(); - Some( - unicode_segmentation::UnicodeSegmentation::graphemes( - &value[..char_offset.min(value.len())], - true, - ) - .count(), + let grapheme_count = unicode_segmentation::UnicodeSegmentation::graphemes( + &value_str[..char_offset.min(value_str.len())], + true, ) + .count(); + + Some((grapheme_count, affinity)) } +#[inline(never)] fn replace_paragraph( state: &mut State, layout: Layout<'_>, @@ -2673,20 +3226,27 @@ fn replace_paragraph( font: ::Font, text_size: Pixels, line_height: text::LineHeight, + limits: &layout::Limits, ) { let mut children_layout = layout.children(); - let text_bounds = children_layout.next().unwrap().bounds(); + let text_bounds = children_layout.next().unwrap(); + let bounds = limits.resolve( + Length::Shrink, + Length::Fill, + Size::new(0., text_bounds.bounds().height), + ); - state.value = crate::Paragraph::with_text(Text { + state.value = crate::Plain::new(Text { font, line_height, - content: &value.to_string(), - bounds: Size::new(f32::INFINITY, text_bounds.height), + content: value.to_string(), + bounds, size: text_size, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Top, + align_x: text::Alignment::Default, + align_y: alignment::Vertical::Top, shaping: text::Shaping::Advanced, - wrap: text::Wrap::default(), + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, }); } @@ -2695,6 +3255,7 @@ const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500; mod platform { use iced_core::keyboard; + #[inline] pub fn is_jump_modifier_pressed(modifiers: keyboard::Modifiers) -> bool { if cfg!(target_os = "macos") { modifiers.alt() @@ -2704,6 +3265,7 @@ mod platform { } } +#[inline(never)] fn offset(text_bounds: Rectangle, value: &Value, state: &State) -> f32 { if state.is_focused() { let cursor = state.cursor(); @@ -2713,11 +3275,48 @@ fn offset(text_bounds: Rectangle, value: &Value, state: &State) -> f32 { cursor::State::Selection { end, .. } => end, }; - let (_, offset) = - measure_cursor_and_scroll_offset(&state.value, text_bounds, focus_position); + let (_, offset) = measure_cursor_and_scroll_offset( + state.value.raw(), + text_bounds, + focus_position, + value, + state.cursor().affinity(), + state.scroll_offset, + ); offset } else { - 0.0 + match effective_alignment(state.value.raw()) { + alignment::Horizontal::Right => { + (state.value.raw().min_width() - text_bounds.width).max(0.0) + } + _ => 0.0, + } + } +} + +#[inline(never)] +fn alignment_offset( + text_bounds_width: f32, + text_min_width: f32, + alignment: alignment::Horizontal, +) -> f32 { + if text_min_width > text_bounds_width { + 0.0 + } else { + match alignment { + alignment::Horizontal::Left => 0.0, + alignment::Horizontal::Center => (text_bounds_width - text_min_width) / 2.0, + alignment::Horizontal::Right => text_bounds_width - text_min_width, + } + } +} + +#[inline(never)] +fn effective_alignment(paragraph: &impl text::Paragraph) -> alignment::Horizontal { + if paragraph.is_rtl(0).unwrap_or(false) { + alignment::Horizontal::Right + } else { + alignment::Horizontal::Left } } diff --git a/src/widget/text_input/style.rs b/src/widget/text_input/style.rs index aa25c86e..8af5e63e 100644 --- a/src/widget/text_input/style.rs +++ b/src/widget/text_input/style.rs @@ -4,7 +4,7 @@ //! Change the appearance of a text input. -use iced_core::{border::Radius, Background, Color}; +use iced_core::{Background, Color, border::Radius}; /// The appearance of a text input. #[derive(Debug, Clone, Copy)] diff --git a/src/widget/text_input/value.rs b/src/widget/text_input/value.rs index b18ea2ca..3f7b8d73 100644 --- a/src/widget/text_input/value.rs +++ b/src/widget/text_input/value.rs @@ -8,7 +8,7 @@ use unicode_segmentation::UnicodeSegmentation; /// /// [`TextInput`]: crate::widget::TextInput // TODO: Reduce allocations, cache results (?) -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone, PartialEq)] pub struct Value { graphemes: Vec, } @@ -27,12 +27,14 @@ impl Value { /// /// A [`Value`] is empty when it contains no graphemes. #[must_use] + #[inline] pub fn is_empty(&self) -> bool { self.len() == 0 } /// Returns the total amount of graphemes in the [`Value`]. #[must_use] + #[inline] pub fn len(&self) -> usize { self.graphemes.len() } @@ -75,6 +77,7 @@ impl Value { /// Returns a new [`Value`] containing the graphemes from `start` until the /// given `end`. #[must_use] + #[inline] pub fn select(&self, start: usize, end: usize) -> Self { let graphemes = self.graphemes[start.min(self.len())..end.min(self.len())].to_vec(); @@ -84,6 +87,7 @@ impl Value { /// Returns a new [`Value`] containing the graphemes until the given /// `index`. #[must_use] + #[inline] pub fn until(&self, index: usize) -> Self { let graphemes = self.graphemes[..index.min(self.len())].to_vec(); @@ -91,6 +95,7 @@ impl Value { } /// Inserts a new `char` at the given grapheme `index`. + #[inline] pub fn insert(&mut self, index: usize, c: char) { self.graphemes.insert(index, c.to_string()); @@ -100,6 +105,7 @@ impl Value { } /// Inserts a bunch of graphemes at the given grapheme `index`. + #[inline] pub fn insert_many(&mut self, index: usize, mut value: Value) { let _ = self .graphemes @@ -107,11 +113,13 @@ impl Value { } /// Removes the grapheme at the given `index`. + #[inline] pub fn remove(&mut self, index: usize) { let _ = self.graphemes.remove(index); } /// Removes the graphemes from `start` to `end`. + #[inline] pub fn remove_many(&mut self, start: usize, end: usize) { let _ = self.graphemes.splice(start..end, std::iter::empty()); } @@ -121,15 +129,45 @@ impl Value { #[must_use] pub fn secure(&self) -> Self { Self { - graphemes: std::iter::repeat(String::from("•")) - .take(self.graphemes.len()) - .collect(), + graphemes: std::iter::repeat_n(String::from("•"), self.graphemes.len()).collect(), } } + + /// Converts a grapheme index to a byte index in the underlying string. + #[must_use] + pub fn byte_index_at_grapheme(&self, grapheme_index: usize) -> usize { + self.graphemes[..grapheme_index.min(self.graphemes.len())] + .iter() + .map(|g| g.len()) + .sum() + } + + /// Returns the grapheme index of the last occurrence of the given character, + /// searching from the end. + #[must_use] + pub fn rfind_char(&self, ch: char) -> Option { + let needle = ch.to_string(); + self.graphemes.iter().rposition(|g| g == &needle) + } + + /// Converts a byte index to a grapheme index. + #[must_use] + pub fn grapheme_index_at_byte(&self, byte_index: usize) -> usize { + let mut bytes = 0; + for (i, g) in self.graphemes.iter().enumerate() { + if bytes >= byte_index { + return i; + } + bytes += g.len(); + } + + self.graphemes.len() + } } -impl ToString for Value { - fn to_string(&self) -> String { - self.graphemes.concat() +impl std::fmt::Display for Value { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.graphemes.concat()) } } diff --git a/src/widget/toaster/mod.rs b/src/widget/toaster/mod.rs index 481d799f..bafaa9f9 100644 --- a/src/widget/toaster/mod.rs +++ b/src/widget/toaster/mod.rs @@ -6,16 +6,14 @@ use std::collections::VecDeque; use std::rc::Rc; -use crate::widget::container; use crate::widget::Column; -use crate::Command; +use crate::widget::container; +use iced::Task; use iced_core::Element; -use slotmap::new_key_type; use slotmap::SlotMap; +use slotmap::new_key_type; use widget::Toaster; -use crate::ext::CollectionWidget; - use super::column; use super::{button, icon, row, text}; @@ -36,10 +34,10 @@ pub fn toaster<'a, Message: Clone + 'static>( } = theme.cosmic().spacing; let make_toast = move |(id, toast): (ToastId, &'a Toast)| { - let row = row() + let row = row::with_capacity(2) .push(text(&toast.message)) .push( - row() + row::with_capacity(2) .push_maybe(toast.action.as_ref().map(|action| { button::text(&action.description).on_press((action.message)(id)) })) @@ -47,15 +45,15 @@ pub fn toaster<'a, Message: Clone + 'static>( button::icon(icon::from_name("window-close-symbolic")) .on_press((toasts.on_close)(id)), ) - .align_items(iced::Alignment::Center) + .align_y(iced::Alignment::Center) .spacing(space_xxs), ) - .align_items(iced::Alignment::Center) + .align_y(iced::Alignment::Center) .spacing(space_s); container(row) .padding([space_xxs, space_s, space_xxs, space_m]) - .style(crate::style::Container::Tooltip) + .class(crate::style::Container::Tooltip) }; let col = toasts @@ -106,6 +104,7 @@ pub struct Action { } impl std::fmt::Debug for Action { + #[cold] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Action") .field("description", &self.description) @@ -175,7 +174,7 @@ impl Toasts { } /// Add a new [`Toast`] - pub fn push(&mut self, toast: Toast) -> Command { + pub fn push(&mut self, toast: Toast) -> Task { while self.toasts.len() >= self.limit { self.toasts.remove( self.queue @@ -193,14 +192,14 @@ impl Toasts { #[cfg(feature = "tokio")] { let on_close = self.on_close; - crate::command::future(async move { + crate::task::future(async move { tokio::time::sleep(duration).await; on_close(id) }) } #[cfg(not(feature = "tokio"))] { - Command::none() + Task::none() } } diff --git a/src/widget/toaster/widget.rs b/src/widget/toaster/widget.rs index 7d9a58c9..de47a9bd 100644 --- a/src/widget/toaster/widget.rs +++ b/src/widget/toaster/widget.rs @@ -4,17 +4,16 @@ use iced::{Limits, Size}; use iced_core::layout::Node; +use iced_core::Element; +use iced_core::Overlay; use iced_core::event::{self, Event}; use iced_core::layout; use iced_core::mouse; use iced_core::overlay; use iced_core::renderer::{self}; -use iced_core::widget::tree::Tree; use iced_core::widget::Operation; -use iced_core::Element; -use iced_core::Overlay; +use iced_core::widget::tree::Tree; use iced_core::{Clipboard, Layout, Length, Point, Rectangle, Shell, Vector, Widget}; -use iced_renderer::core::widget::OperationOutputWrapper; pub struct Toaster<'a, Message, Theme, Renderer> { toasts: Element<'a, Message, Theme, Renderer>, @@ -36,8 +35,8 @@ impl<'a, Message, Theme, Renderer> Toaster<'a, Message, Theme, Renderer> { } } -impl<'a, Message, Theme, Renderer> Widget - for Toaster<'a, Message, Theme, Renderer> +impl Widget + for Toaster<'_, Message, Theme, Renderer> where Renderer: iced_core::Renderer, { @@ -46,13 +45,13 @@ where } fn layout( - &self, + &mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { self.content - .as_widget() + .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) } @@ -86,29 +85,29 @@ where } fn operate<'b>( - &'b self, + &'b mut self, state: &'b mut Tree, layout: Layout<'_>, renderer: &Renderer, - operation: &mut dyn Operation>, + operation: &mut dyn Operation<()>, ) { self.content - .as_widget() + .as_widget_mut() .operate(&mut state.children[0], layout, renderer, operation); } - fn on_event( + fn update( &mut self, state: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, - ) -> event::Status { - self.content.as_widget_mut().on_event( + ) { + self.content.as_widget_mut().update( &mut state.children[0], event, layout, @@ -140,24 +139,27 @@ where fn overlay<'b>( &'b mut self, state: &'b mut Tree, - layout: Layout<'_>, + layout: Layout<'b>, renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, ) -> Option> { //TODO: this hides the overlay of the content during the toast if self.is_empty { - self.content - .as_widget_mut() - .overlay(&mut state.children[0], layout, renderer) + self.content.as_widget_mut().overlay( + &mut state.children[0], + layout, + renderer, + viewport, + translation, + ) } else { let bounds = layout.bounds(); - Some(overlay::Element::new( - bounds.position(), - Box::new(ToasterOverlay::new( - &mut state.children[1], - &mut self.toasts, - )), - )) + Some(overlay::Element::new(Box::new(ToasterOverlay::new( + &mut state.children[1], + &mut self.toasts, + )))) } } @@ -166,7 +168,7 @@ where state: &Tree, layout: Layout<'_>, renderer: &Renderer, - dnd_rectangles: &mut iced_style::core::clipboard::DndDestinationRectangles, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { self.content.as_widget().drag_destinations( &state.children[0], @@ -191,23 +193,17 @@ where } } -impl<'a, 'b, Message, Theme, Renderer> Overlay - for ToasterOverlay<'a, 'b, Message, Theme, Renderer> +impl Overlay + for ToasterOverlay<'_, '_, Message, Theme, Renderer> where Renderer: renderer::Renderer, { - fn layout( - &mut self, - renderer: &Renderer, - bounds: Size, - position: Point, - _translation: Vector, - ) -> Node { + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> Node { let limits = Limits::new(Size::ZERO, bounds); - let mut node = self + let node = self .element - .as_widget() + .as_widget_mut() .layout(self.state, renderer, &limits); let offset = 15.; @@ -217,9 +213,7 @@ where bounds.height - (node.size().height + offset), ); - node.move_to_mut(position); - - node + node.move_to(position) } fn draw( @@ -236,16 +230,16 @@ where .draw(self.state, renderer, theme, style, layout, cursor, &bounds); } - fn on_event( + fn update( &mut self, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell, - ) -> event::Status { - self.element.as_widget_mut().on_event( + ) { + self.element.as_widget_mut().update( self.state, event, layout, @@ -254,29 +248,36 @@ where clipboard, shell, &layout.bounds(), - ) + ); } fn mouse_interaction( &self, layout: Layout<'_>, cursor: mouse::Cursor, - viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { - self.element - .as_widget() - .mouse_interaction(self.state, layout, cursor, viewport, renderer) + self.element.as_widget().mouse_interaction( + self.state, + layout, + cursor, + &layout.bounds(), + renderer, + ) } fn overlay<'c>( &'c mut self, - layout: Layout<'_>, + layout: Layout<'c>, renderer: &Renderer, ) -> Option> { - self.element - .as_widget_mut() - .overlay(self.state, layout, renderer) + self.element.as_widget_mut().overlay( + self.state, + layout, + renderer, + &layout.bounds(), + Default::default(), + ) } } diff --git a/src/widget/toggler.rs b/src/widget/toggler.rs index 4e92ffd4..b95b596e 100644 --- a/src/widget/toggler.rs +++ b/src/widget/toggler.rs @@ -1,18 +1,446 @@ -// Copyright 2022 System76 -// SPDX-License-Identifier: MPL-2.0 +//! Show toggle controls using togglers. -use iced::{widget, Length}; -use iced_core::text; +use std::time::{Duration, Instant}; -pub fn toggler<'a, Message, Theme: iced_widget::toggler::StyleSheet, Renderer>( - label: impl Into>, - is_checked: bool, - f: impl Fn(bool) -> Message + 'a, -) -> widget::Toggler<'a, Message, Theme, Renderer> -where - Renderer: iced_core::Renderer + text::Renderer, -{ - widget::Toggler::new(label, is_checked, f) - .size(24) - .width(Length::Shrink) +use crate::{Element, anim}; +use iced_core::{ + Border, Clipboard, Event, Layout, Length, Pixels, Rectangle, Shell, Size, Widget, alignment, + event, layout, mouse, + renderer::{self, Renderer}, + text, touch, + widget::{self, Tree, tree}, + window, +}; +use iced_widget::{Id, toggler::Status}; + +pub use iced_widget::toggler::{Catalog, Style}; + +pub fn toggler<'a, Message>(is_checked: bool) -> Toggler<'a, Message> { + Toggler::new(is_checked) +} +/// A toggler widget. +#[allow(missing_debug_implementations)] +pub struct Toggler<'a, Message> { + id: Id, + is_toggled: bool, + on_toggle: Option Message + 'a>>, + label: Option, + width: Length, + size: f32, + text_size: Option, + text_line_height: text::LineHeight, + text_alignment: text::Alignment, + text_shaping: text::Shaping, + spacing: f32, + font: Option, + duration: Duration, + ellipsize: text::Ellipsize, +} + +impl<'a, Message> Toggler<'a, Message> { + /// The default size of a [`Toggler`]. + pub const DEFAULT_SIZE: f32 = 24.0; + + /// Creates a new [`Toggler`]. + /// + /// It expects: + /// * a boolean describing whether the [`Toggler`] is checked or not + /// * An optional label for the [`Toggler`] + /// * a function that will be called when the [`Toggler`] is toggled. It + /// will receive the new state of the [`Toggler`] and must produce a + /// `Message`. + pub fn new(is_toggled: bool) -> Self { + Toggler { + id: Id::unique(), + is_toggled, + on_toggle: None, + label: None, + width: Length::Shrink, + size: Self::DEFAULT_SIZE, + text_size: None, + text_line_height: text::LineHeight::default(), + text_alignment: text::Alignment::Left, + text_shaping: text::Shaping::Advanced, + spacing: 0.0, + font: None, + duration: Duration::from_millis(200), + ellipsize: text::Ellipsize::None, + } + } + + /// Sets the size of the [`Toggler`]. + pub fn size(mut self, size: impl Into) -> Self { + self.size = size.into().0; + self + } + + /// Sets the width of the [`Toggler`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the text size o the [`Toggler`]. + pub fn text_size(mut self, text_size: impl Into) -> Self { + self.text_size = Some(text_size.into().0); + self + } + + /// Sets the text [`LineHeight`] of the [`Toggler`]. + pub fn text_line_height(mut self, line_height: impl Into) -> Self { + self.text_line_height = line_height.into(); + self + } + + /// Sets the horizontal alignment of the text of the [`Toggler`] + pub fn text_alignment(mut self, alignment: text::Alignment) -> Self { + self.text_alignment = alignment; + self + } + + /// Sets the [`text::Shaping`] strategy of the [`Toggler`]. + pub fn text_shaping(mut self, shaping: text::Shaping) -> Self { + self.text_shaping = shaping; + self + } + + /// Sets the spacing between the [`Toggler`] and the text. + pub fn spacing(mut self, spacing: impl Into) -> Self { + self.spacing = spacing.into().0; + self + } + + /// Sets the [`text::Ellipsize`] strategy of the [`Toggler`]. + pub fn ellipsize(mut self, ellipsize: text::Ellipsize) -> Self { + self.ellipsize = ellipsize; + self + } + + /// Sets the [`Font`] of the text of the [`Toggler`] + /// + /// [`Font`]: cosmic::iced::text::Renderer::Font + pub fn font(mut self, font: impl Into) -> Self { + self.font = Some(font.into()); + self + } + + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } + + pub fn duration(mut self, dur: Duration) -> Self { + self.duration = dur; + self + } + + pub fn on_toggle(mut self, on_toggle: impl Fn(bool) -> Message + 'a) -> Self { + self.on_toggle = Some(Box::new(on_toggle)); + self + } + + pub fn on_toggle_maybe(mut self, on_toggle: Option Message + 'a>) -> Self { + self.on_toggle = on_toggle.map(|t| Box::new(t) as _); + self + } + + /// Sets the label of the [`Button`]. + pub fn label(mut self, label: impl Into>) -> Self { + self.label = label.into(); + self + } +} + +impl<'a, Message> Widget for Toggler<'a, Message> { + fn size(&self) -> Size { + Size::new(self.width, Length::Shrink) + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State { + prev_toggled: self.is_toggled, + ..State::default() + }) + } + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn set_id(&mut self, id: Id) { + self.id = id; + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &crate::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let limits = limits.width(self.width); + + let res = next_to_each_other( + &limits, + self.spacing, + |limits| { + if let Some(label) = self.label.as_deref() { + let state = tree.state.downcast_mut::(); + let node = iced_core::widget::text::layout( + &mut state.text, + renderer, + limits, + label, + widget::text::Format { + width: self.width, + height: Length::Shrink, + line_height: self.text_line_height, + size: self.text_size.map(iced::Pixels), + font: self.font, + align_x: self.text_alignment, + align_y: alignment::Vertical::Top, + shaping: self.text_shaping, + wrapping: iced_core::text::Wrapping::default(), + ellipsize: self.ellipsize, + }, + ); + match self.width { + Length::Fill => { + let size = node.size(); + layout::Node::with_children( + Size::new(limits.width(Length::Fill).max().width, size.height), + vec![node], + ) + } + _ => node, + } + } else { + layout::Node::new(iced_core::Size::ZERO) + } + }, + |_| layout::Node::new(Size::new(48., 24.)), + ); + res + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + _renderer: &crate::Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) { + let Some(on_toggle) = self.on_toggle.as_ref() else { + return; + }; + let state = tree.state.downcast_mut::(); + + // animate external changes + if state.prev_toggled != self.is_toggled { + state.anim.changed(self.duration); + shell.request_redraw(); + state.prev_toggled = self.is_toggled; + } + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + let mouse_over = cursor_position.is_over(layout.bounds()); + + if mouse_over { + shell.publish((on_toggle)(!self.is_toggled)); + state.anim.changed(self.duration); + state.prev_toggled = !self.is_toggled; + shell.capture_event(); + } + } + Event::Window(window::Event::RedrawRequested(now)) => { + state.anim.anim_done(self.duration); + if state.anim.last_change.is_some() { + shell.request_redraw(); + } + } + _ => {} + } + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &crate::Renderer, + ) -> mouse::Interaction { + if cursor_position.is_over(layout.bounds()) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: mouse::Cursor, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + + let mut children = layout.children(); + let label_layout = children.next().unwrap(); + + if let Some(_label) = &self.label { + let state: &State = tree.state.downcast_ref(); + iced_widget::text::draw( + renderer, + style, + label_layout.bounds(), + state.text.raw(), + iced_widget::text::Style::default(), + viewport, + ); + } + + let toggler_layout = children.next().unwrap(); + let bounds = toggler_layout.bounds(); + + let is_mouse_over = cursor_position.is_over(bounds); + + // let style = blend_appearances( + // theme.style( + // &(), + // if is_mouse_over { + // Status::Hovered { is_toggled: false } + // } else { + // Status::Active { is_toggled: false } + // }, + // ), + // theme.style( + // &(), + // if is_mouse_over { + // Status::Hovered { is_toggled: true } + // } else { + // Status::Active { is_toggled: true } + // }, + // ), + // percent, + // ); + + let style = theme.style( + &(), + if is_mouse_over { + Status::Hovered { + is_toggled: self.is_toggled, + } + } else { + Status::Active { + is_toggled: self.is_toggled, + } + }, + ); + + let space = style.handle_margin; + + let toggler_background_bounds = Rectangle { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; + + renderer.fill_quad( + renderer::Quad { + bounds: toggler_background_bounds, + border: Border { + radius: style.border_radius, + ..Default::default() + }, + ..renderer::Quad::default() + }, + style.background, + ); + let mut t = state.anim.t(self.duration, self.is_toggled); + + let toggler_foreground_bounds = Rectangle { + x: bounds.x + + anim::slerp( + space, + bounds.width - space - (bounds.height - (2.0 * space)), + t, + ), + + y: bounds.y + space, + width: bounds.height - (2.0 * space), + height: bounds.height - (2.0 * space), + }; + + renderer.fill_quad( + renderer::Quad { + bounds: toggler_foreground_bounds, + border: Border { + radius: style.handle_radius, + ..Default::default() + }, + ..renderer::Quad::default() + }, + style.foreground, + ); + } +} + +impl<'a, Message: 'static> From> for Element<'a, Message> { + fn from(toggler: Toggler<'a, Message>) -> Element<'a, Message> { + Element::new(toggler) + } +} + +/// Produces a [`Node`] with two children nodes one right next to each other. +pub fn next_to_each_other( + limits: &iced::Limits, + spacing: f32, + left: impl FnOnce(&iced::Limits) -> iced_core::layout::Node, + right: impl FnOnce(&iced::Limits) -> iced_core::layout::Node, +) -> iced_core::layout::Node { + let mut right_node = right(limits); + let right_size = right_node.size(); + + let left_limits = limits.shrink(Size::new(right_size.width + spacing, 0.0)); + let mut left_node = left(&left_limits); + let left_size = left_node.size(); + + let (left_y, right_y) = if left_size.height > right_size.height { + (0.0, (left_size.height - right_size.height) / 2.0) + } else { + ((right_size.height - left_size.height) / 2.0, 0.0) + }; + + left_node = left_node.move_to(iced::Point::new(0.0, left_y)); + right_node = right_node.move_to(iced::Point::new(left_size.width + spacing, right_y)); + + iced_core::layout::Node::with_children( + Size::new( + left_size.width + spacing + right_size.width, + left_size.height.max(right_size.height), + ), + vec![left_node, right_node], + ) +} + +#[derive(Debug, Default)] +pub struct State { + text: widget::text::State<::Paragraph>, + anim: anim::State, + prev_toggled: bool, } diff --git a/src/widget/warning.rs b/src/widget/warning.rs index a6450d8c..4153d647 100644 --- a/src/widget/warning.rs +++ b/src/widget/warning.rs @@ -2,9 +2,9 @@ // SPDX-License-Identifier: MPL-2.0 use super::icon; -use crate::{theme, widget, Element, Renderer, Theme}; +use crate::{Element, Renderer, Theme, theme, widget}; use apply::Apply; -use iced::{alignment, Alignment, Background, Color, Length}; +use iced::{Alignment, Background, Color, Length}; use iced_core::{Border, Shadow}; use std::borrow::Cow; @@ -41,11 +41,11 @@ impl<'a, Message: 'static + Clone> Warning<'a, Message> { widget::row::with_capacity(2) .push(label) .push(close_button) - .align_items(Alignment::Center) + .align_y(Alignment::Center) .apply(widget::container) - .style(theme::Container::custom(warning_container)) + .class(theme::Container::custom(warning_container)) .padding(10) - .align_y(alignment::Vertical::Center) + .align_y(Alignment::Center) .width(Length::Fill) } } @@ -57,9 +57,9 @@ impl<'a, Message: 'static + Clone> From> for Element<'a, Me } #[must_use] -pub fn warning_container(theme: &Theme) -> widget::container::Appearance { +pub fn warning_container(theme: &Theme) -> widget::container::Style { let cosmic = theme.cosmic(); - widget::container::Appearance { + widget::container::Style { icon_color: Some(theme.cosmic().warning.on.into()), text_color: Some(theme.cosmic().warning.on.into()), background: Some(Background::Color(theme.cosmic().warning_color().into())), @@ -73,5 +73,6 @@ pub fn warning_container(theme: &Theme) -> widget::container::Appearance { offset: iced::Vector::new(0.0, 0.0), blur_radius: 0.0, }, + snap: true, } } diff --git a/src/widget/wayland/mod.rs b/src/widget/wayland/mod.rs new file mode 100644 index 00000000..7c53d374 --- /dev/null +++ b/src/widget/wayland/mod.rs @@ -0,0 +1 @@ +pub mod tooltip; diff --git a/src/widget/wayland/tooltip/mod.rs b/src/widget/wayland/tooltip/mod.rs new file mode 100644 index 00000000..947d1e83 --- /dev/null +++ b/src/widget/wayland/tooltip/mod.rs @@ -0,0 +1,76 @@ +//! Change the apperance of a tooltip. + +pub mod widget; + +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +use iced_core::{Background, Color, Vector, border::Radius}; + +use crate::theme::THEME; + +/// The appearance of a tooltip. +#[must_use] +#[derive(Debug, Clone, Copy)] +pub struct Style { + /// The amount of offset to apply to the shadow of the tooltip. + pub shadow_offset: Vector, + + /// The [`Background`] of the tooltip. + pub background: Option, + + /// The border radius of the tooltip. + pub border_radius: Radius, + + /// The border width of the tooltip. + pub border_width: f32, + + /// The border [`Color`] of the tooltip. + pub border_color: Color, + + /// An outline placed around the border. + pub outline_width: f32, + + /// The [`Color`] of the outline. + pub outline_color: Color, + + /// The icon [`Color`] of the tooltip. + pub icon_color: Option, + + /// The text [`Color`] of the tooltip. + pub text_color: Color, +} + +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: 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: Color::BLACK, + } + } +} + +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 tooltip. +pub trait Catalog { + /// The supported style of the [`StyleSheet`]. + type Class: Default; + + /// Produces the active [`Appearance`] of a tooltip. + fn style(&self, style: &Self::Class) -> Style; +} diff --git a/src/widget/wayland/tooltip/widget.rs b/src/widget/wayland/tooltip/widget.rs new file mode 100644 index 00000000..7bf0991a --- /dev/null +++ b/src/widget/wayland/tooltip/widget.rs @@ -0,0 +1,707 @@ +// Copyright 2019 H�ctor Ram�n, Iced contributors +// Copyright 2023 System76 +// SPDX-License-Identifier: MIT + +//! Allow your users to perform actions by pressing a button. +//! +//! A [`Tooltip`] has some local [`State`]. + +use std::any::Any; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use iced::Task; +use iced_runtime::core::widget::Id; + +use iced_core::event::{self, Event}; +use iced_core::renderer; +use iced_core::touch; +use iced_core::widget::Operation; +use iced_core::widget::tree::{self, Tree}; +use iced_core::{ + Background, Clipboard, Color, Layout, Length, Padding, Point, Rectangle, Shell, Vector, Widget, +}; +use iced_core::{Border, mouse}; +use iced_core::{Shadow, overlay}; +use iced_core::{layout, svg}; + +pub use super::{Catalog, Style}; + +/// Internally defines different button widget variants. +enum Variant { + Normal, + Image { + close_icon: svg::Handle, + on_remove: Option, + }, +} + +/// A generic button which emits a message when pressed. +#[allow(missing_debug_implementations)] +#[must_use] +pub struct Tooltip<'a, Message, TopLevelMessage> { + id: Id, + #[cfg(feature = "a11y")] + name: Option>, + #[cfg(feature = "a11y")] + description: Option>, + #[cfg(feature = "a11y")] + label: Option>, + content: crate::Element<'a, Message>, + on_leave: Message, + on_surface_action: Box Message>, + width: Length, + height: Length, + padding: Padding, + selected: bool, + style: crate::theme::Tooltip, + delay: Option, + settings: Option< + Arc< + dyn Fn(Rectangle) -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + >, + >, + view: Arc< + dyn Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, + >, +} + +impl<'a, Message, TopLevelMessage> Tooltip<'a, Message, TopLevelMessage> { + /// Creates a new [`Tooltip`] with the given content. + pub fn new( + content: impl Into>, + settings: Option< + impl Fn(Rectangle) -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + >, + view: impl Fn() -> crate::Element<'static, crate::Action> + + Send + + Sync + + 'static, + on_leave: Message, + on_surface_action: impl Fn(crate::surface::Action) -> Message + 'static, + ) -> Self { + Self { + id: Id::unique(), + #[cfg(feature = "a11y")] + name: None, + #[cfg(feature = "a11y")] + description: None, + #[cfg(feature = "a11y")] + label: None, + content: content.into(), + width: Length::Shrink, + height: Length::Shrink, + padding: Padding::new(0.0), + selected: false, + style: crate::theme::Tooltip::default(), + on_leave, + on_surface_action: Box::new(on_surface_action), + delay: None, + settings: if let Some(s) = settings { + Some(Arc::new(s)) + } else { + None + }, + view: Arc::new(view), + } + } + + pub fn delay(mut self, dur: Duration) -> Self { + self.delay = Some(dur); + self + } + + /// Sets the [`Id`] of the [`Tooltip`]. + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } + + /// Sets the width of the [`Tooltip`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Tooltip`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the [`Padding`] of the [`Tooltip`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the widget to a selected state. + /// + /// Displays a selection indicator on image buttons. + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + + self + } + + /// Sets the style variant of this [`Tooltip`]. + pub fn class(mut self, style: crate::theme::Tooltip) -> Self { + self.style = style; + self + } + + #[cfg(feature = "a11y")] + /// Sets the name of the [`Tooltip`]. + pub fn name(mut self, name: impl Into>) -> Self { + self.name = Some(name.into()); + self + } + + #[cfg(feature = "a11y")] + /// Sets the description of the [`Tooltip`]. + pub fn description_widget(mut self, description: &T) -> Self { + self.description = Some(iced_accessibility::Description::Id( + description.description(), + )); + self + } + + #[cfg(feature = "a11y")] + /// Sets the description of the [`Tooltip`]. + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(iced_accessibility::Description::Text(description.into())); + self + } + + #[cfg(feature = "a11y")] + /// Sets the label of the [`Tooltip`]. + pub fn label(mut self, label: &dyn iced_accessibility::Labels) -> Self { + self.label = Some(label.label().into_iter().map(|l| l.into()).collect()); + self + } +} + +impl<'a, Message: 'static + Clone, TopLevelMessage: 'static + Clone> + Widget for Tooltip<'a, Message, TopLevelMessage> +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + 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 { + iced_core::Size::new(self.width, self.height) + } + + 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_mut() + .layout(&mut tree.children[0], renderer, limits) + }, + ) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &crate::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: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + update( + self.id.clone(), + event.clone(), + layout, + cursor, + shell, + self.settings.as_ref(), + &self.view, + self.delay, + &self.on_leave, + &self.on_surface_action, + || tree.state.downcast_mut::(), + ); + + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout + .children() + .next() + .unwrap() + .with_virtual_offset(layout.virtual_offset()), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + #[allow(clippy::too_many_lines)] + fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + if !viewport.intersects(&bounds) { + return; + } + let content_layout = layout.children().next().unwrap(); + + let state = tree.state.downcast_ref::(); + + let styling = theme.style(&self.style); + + let icon_color = styling.icon_color.unwrap_or(renderer_style.icon_color); + + draw::<_, crate::Theme>( + renderer, + bounds, + *viewport, + &styling, + |renderer, _styling| { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + &renderer::Style { + icon_color, + text_color: styling.text_color, + scale_factor: renderer_style.scale_factor, + }, + content_layout.with_virtual_offset(layout.virtual_offset()), + cursor, + &viewport.intersection(&bounds).unwrap_or_default(), + ); + }, + ); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout.children().next().unwrap(), + cursor, + viewport, + renderer, + ) + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + 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() + .with_virtual_offset(layout.virtual_offset()), + 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 { + let c_layout = layout.children().next().unwrap(); + + self.content.as_widget().a11y_nodes( + c_layout.with_virtual_offset(layout.virtual_offset()), + state, + p, + ) + } + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn set_id(&mut self, id: Id) { + self.id = id; + } +} + +impl<'a, Message: Clone + 'static, TopLevelMessage: Clone + 'static> + From> for crate::Element<'a, Message> +{ + fn from(button: Tooltip<'a, Message, TopLevelMessage>) -> Self { + Self::new(button) + } +} + +/// The local state of a [`Tooltip`]. +#[derive(Debug, Clone, Default)] +#[allow(clippy::struct_field_names)] +pub struct State { + is_hovered: Arc>, +} + +impl State { + /// Returns whether the [`Tooltip`] is currently hovered or not. + pub fn is_hovered(self) -> bool { + let guard = self.is_hovered.lock().unwrap(); + *guard + } +} + +/// Processes the given [`Event`] and updates the [`State`] of a [`Tooltip`] +/// accordingly. +#[allow(clippy::needless_pass_by_value)] +pub fn update<'a, Message: Clone + 'static, TopLevelMessage: Clone + 'static>( + _id: Id, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + shell: &mut Shell<'_, Message>, + settings: Option< + &Arc< + dyn Fn(Rectangle) -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + >, + >, + view: &Arc< + dyn Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, + >, + delay: Option, + on_leave: &Message, + on_surface_action: &dyn Fn(crate::surface::Action) -> Message, + state: impl FnOnce() -> &'a mut State, +) { + match event { + Event::Touch(touch::Event::FingerLifted { .. }) => { + let state = state(); + let mut guard = state.is_hovered.lock().unwrap(); + if *guard { + *guard = false; + + shell.publish(on_leave.clone()); + + shell.capture_event(); + return; + } + } + + Event::Touch(touch::Event::FingerLost { .. }) | Event::Mouse(mouse::Event::CursorLeft) => { + let state = state(); + let mut guard = state.is_hovered.lock().unwrap(); + + if *guard { + *guard = false; + + shell.publish(on_leave.clone()); + } + } + + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + let state = state(); + let bounds = layout.bounds(); + let is_hovered = state.is_hovered.clone(); + let mut guard = state.is_hovered.lock().unwrap(); + + if *guard { + *guard = cursor.is_over(bounds); + if !*guard { + shell.publish(on_leave.clone()); + } + } else { + *guard = cursor.is_over(bounds); + if *guard { + if let Some(settings) = settings { + if let Some(delay) = delay { + let s = settings.clone(); + let view = view.clone(); + let bounds = layout.bounds(); + + let sm = crate::surface::Action::Task(Arc::new(move || { + let s = s.clone(); + let view = view.clone(); + let is_hovered = is_hovered.clone(); + Task::future(async move { + #[cfg(feature = "tokio")] + { + _ = tokio::time::sleep(delay).await; + } + #[cfg(feature = "async-std")] + { + _ = async_std::task::sleep(delay).await; + } + let is_hovered = is_hovered.clone(); + let g = is_hovered.lock().unwrap(); + if !*g { + return crate::surface::Action::Ignore; + } + let boxed: Box< + dyn Fn() -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + > = Box::new(move || s(bounds)); + let boxed: Box = + Box::new(boxed); + crate::surface::Action::Popup( + Arc::new(boxed), + Some({ + let boxed: Box< + dyn Fn() -> crate::Element< + 'static, + crate::Action, + > + Send + + Sync + + 'static, + > = Box::new(move || view()); + let boxed: Box = + Box::new(boxed); + Arc::new(boxed) + }), + ) + }) + })); + + shell.publish((on_surface_action)(sm)); + } else { + let s = settings.clone(); + let view = view.clone(); + let bounds = layout.bounds(); + + let boxed: Box< + dyn Fn() -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + + Send + + Sync + + 'static, + > = Box::new(move || s(bounds)); + let boxed: Box = Box::new(boxed); + + let sm = crate::surface::Action::Popup( + Arc::new(boxed), + Some({ + let boxed: Box< + dyn Fn() -> crate::Element< + 'static, + crate::Action, + > + Send + + Sync + + 'static, + > = Box::new(move || view()); + let boxed: Box = + Box::new(boxed); + Arc::new(boxed) + }), + ); + shell.publish((on_surface_action)(sm)); + } + } + } + } + } + _ => {} + } +} + +#[allow(clippy::too_many_arguments)] +pub fn draw( + renderer: &mut Renderer, + bounds: Rectangle, + viewport_bounds: Rectangle, + styling: &super::Style, + draw_contents: impl FnOnce(&mut Renderer, &Style), +) where + Theme: super::Catalog, +{ + let doubled_border_width = styling.border_width * 2.0; + let doubled_outline_width = styling.outline_width * 2.0; + + if styling.outline_width > 0.0 { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x - styling.border_width - styling.outline_width, + y: bounds.y - styling.border_width - styling.outline_width, + width: bounds.width + doubled_border_width + doubled_outline_width, + height: bounds.height + doubled_border_width + doubled_outline_width, + }, + border: Border { + width: styling.outline_width, + color: styling.outline_color, + radius: styling.border_radius, + }, + shadow: Shadow::default(), + snap: true, + }, + Color::TRANSPARENT, + ); + } + + if styling.background.is_some() || styling.border_width > 0.0 { + if styling.shadow_offset != Vector::default() { + // TODO: Implement proper shadow support + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x + styling.shadow_offset.x, + y: bounds.y + styling.shadow_offset.y, + width: bounds.width, + height: bounds.height, + }, + border: Border { + radius: styling.border_radius, + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }, + Background::Color([0.0, 0.0, 0.0, 0.5].into()), + ); + } + + // Draw the button background first. + if let Some(background) = styling.background { + renderer.fill_quad( + renderer::Quad { + bounds, + border: Border { + radius: styling.border_radius, + ..Default::default() + }, + shadow: Shadow::default(), + snap: true, + }, + background, + ); + } + + // Then draw the button contents onto the background. + draw_contents(renderer, styling); + + let mut clipped_bounds = viewport_bounds.intersection(&bounds).unwrap_or_default(); + clipped_bounds.height += styling.border_width; + + renderer.with_layer(clipped_bounds, |renderer| { + // Finish by drawing the border above the contents. + renderer.fill_quad( + renderer::Quad { + bounds, + border: Border { + width: styling.border_width, + color: styling.border_color, + radius: styling.border_radius, + }, + shadow: Shadow::default(), + snap: true, + }, + Color::TRANSPARENT, + ); + }); + } else { + draw_contents(renderer, styling); + } +} + +/// Computes the layout of a [`Tooltip`]. +pub fn layout( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + height: Length, + padding: Padding, + layout_content: impl FnOnce(&Renderer, &layout::Limits) -> layout::Node, +) -> layout::Node { + let limits = limits.width(width).height(height); + + let mut content = layout_content(renderer, &limits.shrink(padding)); + let padding = padding.fit(content.size(), limits.max()); + let size = limits + .shrink(padding) + .resolve(width, height, content.size()) + .expand(padding); + + content = content.move_to(Point::new(padding.left, padding.top)); + + layout::Node::with_children(size, vec![content]) +} diff --git a/src/widget/wrapper.rs b/src/widget/wrapper.rs new file mode 100644 index 00000000..133f9b87 --- /dev/null +++ b/src/widget/wrapper.rs @@ -0,0 +1,227 @@ +use std::{ + borrow::Borrow, + cell::RefCell, + rc::Rc, + thread::{self, ThreadId}, +}; + +use crate::Element; +use iced::{Length, Rectangle, Size, event}; +use iced_core::{Widget, id::Id, widget, widget::tree}; + +#[derive(Debug)] +pub struct RcWrapper { + pub(crate) data: Rc>, + pub(crate) thread_id: ThreadId, +} + +impl Default for RcWrapper { + fn default() -> Self { + Self::new(T::default()) + } +} + +impl Clone for RcWrapper { + fn clone(&self) -> Self { + Self { + data: self.data.clone(), + thread_id: self.thread_id, + } + } +} + +unsafe impl Send for RcWrapper {} +unsafe impl Sync for RcWrapper {} + +impl RcWrapper { + pub fn new(element: T) -> Self { + Self { + data: Rc::new(RefCell::new(element)), + thread_id: thread::current().id(), + } + } + + /// # Panics + /// + /// Will panic if used outside of original thread. + pub fn with_data(&self, f: impl FnOnce(&T) -> O) -> O { + assert_eq!(self.thread_id, thread::current().id()); + let my_ref: &T = &RefCell::borrow(self.data.as_ref()); + f(my_ref) + } + + /// # Panics + /// + /// Will panic if used outside of original thread. + pub fn with_data_mut(&self, f: impl FnOnce(&mut T) -> O) -> O { + assert_eq!(self.thread_id, thread::current().id()); + let my_refmut: &mut T = &mut RefCell::borrow_mut(self.data.as_ref()); + f(my_refmut) + } +} + +#[derive(Clone)] +pub struct RcElementWrapper { + pub(crate) element: RcWrapper>, +} + +impl RcElementWrapper { + #[must_use] + pub fn new(element: Element<'static, M>) -> Self { + RcElementWrapper { + element: RcWrapper::new(element), + } + } +} + +impl Borrow> for RcElementWrapper { + fn borrow(&self) -> &(dyn Widget + 'static) { + self + } +} + +impl Widget for RcElementWrapper { + fn size(&self) -> Size { + self.element.with_data(|e| e.as_widget().size()) + } + + fn size_hint(&self) -> Size { + self.element.with_data(move |e| e.as_widget().size_hint()) + } + + fn layout( + &mut self, + tree: &mut tree::Tree, + renderer: &crate::Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + self.element + .with_data_mut(|e| e.as_widget_mut().layout(tree, renderer, limits)) + } + + fn draw( + &self, + tree: &tree::Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &iced_core::renderer::Style, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + viewport: &Rectangle, + ) { + self.element.with_data(move |e| { + e.as_widget() + .draw(tree, renderer, theme, style, layout, cursor, viewport); + }); + } + + fn tag(&self) -> tree::Tag { + self.element.with_data(|e| e.as_widget().tag()) + } + + fn state(&self) -> tree::State { + self.element.with_data(|e| e.as_widget().state()) + } + + fn children(&self) -> Vec { + self.element.with_data(|e| e.as_widget().children()) + } + + fn diff(&mut self, tree: &mut tree::Tree) { + self.element.with_data_mut(|e| e.as_widget_mut().diff(tree)); + } + + fn operate( + &mut self, + state: &mut tree::Tree, + layout: iced_core::Layout<'_>, + renderer: &crate::Renderer, + operation: &mut dyn widget::Operation, + ) { + self.element.with_data_mut(|e| { + e.as_widget_mut() + .operate(state, layout, renderer, operation); + }); + } + + fn update( + &mut self, + state: &mut tree::Tree, + event: &crate::iced::Event, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn iced_core::Clipboard, + shell: &mut iced_core::Shell<'_, M>, + viewport: &Rectangle, + ) { + self.element.with_data_mut(|e| { + e.as_widget_mut().update( + state, event, layout, cursor, renderer, clipboard, shell, viewport, + ) + }) + } + + fn mouse_interaction( + &self, + state: &tree::Tree, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + viewport: &Rectangle, + renderer: &crate::Renderer, + ) -> iced_core::mouse::Interaction { + self.element.with_data(|e| { + e.as_widget() + .mouse_interaction(state, layout, cursor, viewport, renderer) + }) + } + + fn overlay<'a>( + &'a mut self, + state: &'a mut tree::Tree, + layout: iced_core::Layout<'a>, + renderer: &crate::Renderer, + viewport: &Rectangle, + translation: iced_core::Vector, + ) -> Option> { + assert_eq!(self.element.thread_id, thread::current().id()); + Rc::get_mut(&mut self.element.data).and_then(|e| { + e.get_mut() + .as_widget_mut() + .overlay(state, layout, renderer, viewport, translation) + }) + } + + fn id(&self) -> Option { + self.element.with_data_mut(|e| e.as_widget_mut().id()) + } + + fn set_id(&mut self, id: Id) { + self.element.with_data_mut(|e| e.as_widget_mut().set_id(id)); + } + + fn drag_destinations( + &self, + state: &tree::Tree, + layout: iced_core::Layout<'_>, + renderer: &crate::Renderer, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, + ) { + self.element.with_data_mut(|e| { + e.as_widget_mut() + .drag_destinations(state, layout, renderer, dnd_rectangles); + }); + } +} + +impl From> for Element<'static, Message> { + fn from(wrapper: RcElementWrapper) -> Self { + Element::new(wrapper) + } +} + +impl From> for RcElementWrapper { + fn from(e: Element<'static, Message>) -> Self { + RcElementWrapper::new(e) + } +}