diff --git a/examples/applet/src/window.rs b/examples/applet/src/window.rs index 57546ab1..8722a2a4 100644 --- a/examples/applet/src/window.rs +++ b/examples/applet/src/window.rs @@ -123,8 +123,6 @@ impl cosmic::Application for Window { }, Some(Box::new(move |state: &Window| { let content_list = list_column() - .padding(5) - .spacing(0) .add(settings::item( "Example row", cosmic::widget::container( diff --git a/iced b/iced index de124712..d9fe129f 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit de1247123a631087d2f78831c99f5f1b66d5a582 +Subproject commit d9fe129f7316cea3ef6d2576e9421a305759b8bf diff --git a/justfile b/justfile index 4653434e..7d9aecee 100644 --- a/justfile +++ b/justfile @@ -23,6 +23,12 @@ check-json: (check '--message-format=json') clean: cargo clean +# Generate documentation +doc: + env RUSTDOCFLAGS="--cfg docsrs" cargo +nightly-2026-04-27 doc --no-deps --verbose \ + -p cosmic-client-toolkit -p cosmic-protocols -p libcosmic \ + --features tokio,winit,wayland,desktop,single-instance,applet,xdg-portal,multi-window + # Also remove .cargo and vendored dependencies clean-dist: clean rm -rf .cargo vendor vendor.tar target diff --git a/src/lib.rs b/src/lib.rs index 9c31506d..e22a3a1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,89 @@ #![cfg_attr(target_os = "redox", feature(lazy_cell))] #![cfg_attr(docsrs, feature(doc_cfg))] +//! # The COSMIC Toolkit +//! +//! Quickly code your project with modular elements created with customization in mind. +//! Panels, applets, theming, tiling, launcher, app library, keyboard shortcuts and +//! dynamic or pinned workspaces are all made flexible to bend to your users needs. +//! +//! [COSMIC](https://system76.com/cosmic) empowers frictionless development by being +//! approachable, easy to maintain, and modular. Use the same language and toolkit to +//! build apps and applets, with helpful templates provided. Even shell components and +//! the compositor use the same toolkit. Learn once and use your knowledge anywhere in +//! the desktop. +//! +//! ## Architecture +//! +//! Based on [iced](https://iced.rs/), COSMIC apps and applets are modeled with the +//! MVU (Model-View-Update) pattern from [The Elm Architecture](https://guide.elm-lang.org/architecture/). +//! For more details, see the [architecture page of the iced book](https://book.iced.rs/architecture.html). +//! +//! An application will consist of: +//! +//! * An application **model** for holding persistent application state which implements +//! the [Application] trait. +//! * A [view](Application::view) function which borrows data from the model to construct +//! a view with stateless widgets +//! * An [update](Application::update) function which receives application messages from +//! widgets, tasks, and subscriptions. +//! +//! Messages handled by the update function are used to update the +//! application model and spawn background tasks that can emit messages back to the +//! app's update function. +//! +//! ### Tasks +//! +//! Tasks returned by the update function are scheduled for concurrent execution on a +//! background thread managed by the application's async executor, which is tokio by default. +//! They can be constructed from futures and may also stream events back to the application +//! asynchronously. +//! +//! ### Subscriptions +//! +//! Applications may also use a [subscription](Application::subscription) function to subscribe to external +//! asynchronous event streams. These can run perpetually from application start; or +//! optionally started, stopped, and restarted based on changes to the application model +//! between updates. Such as: +//! +//! * Conditionally starting and stopping a subscription based on the state of a boolean +//! value or enum +//! * Restarting a subscription when the hash of its borrowed data changes +//! * Dynamically spawning a subscription for each item in a list. +//! +//! ## Templates +//! +//! Get started using [cargo-generate](https://github.com/cargo-generate/cargo-generate) +//! with one of the following templates. The app template is for developing desktop +//! applications and the applet template is for developing COSMIC applets. +//! +//! - [App Template](https://github.com/pop-os/cosmic-app-template/) +//! - [Applet Template](https://github.com/pop-os/cosmic-applet-template/) +//! +//! ## Widgets +//! +//! Reference the [`widget`] module for available widgets for use in the view function. +//! Widgets are composable and can be configured through chainable builder methods. +//! Compose widgets together to create complex interfaces and higher level widgets. +//! +//! Composed widgets may be managed by their own custom type with its own view and +//! update functions. It is a common pattern to use these functions within the +//! application's own view and update functions. If using a custom type, implement +//! [From] for the [Element] type to have API treat a composed widget the same as a +//! native custom widget. +//! +//! If a widget does not exist for a specific use case, use the [Widget](iced::advanced::Widget) +//! trait to create an advanced custom widget. This can then be used to composed higher +//! level widgets by chaining composable widgets together. +//! +//! ## Core +//! +//! Every application model requires a [cosmic::Core](app::Core). This contains +//! application state which is managed by libcosmic's runtime for its generated +//! interfaces. Such as the context drawner, nav bar, and the headerbar. This can be +//! used by the app to subscribe to configuration changes and to emit events to the +//! libcosmic-managed portion of the application's state and view. + /// Recommended default imports. pub mod prelude { pub use crate::ApplicationExt; diff --git a/src/widget/menu/key_bind.rs b/src/widget/menu/key_bind.rs index ef87b344..b764fde7 100644 --- a/src/widget/menu/key_bind.rs +++ b/src/widget/menu/key_bind.rs @@ -51,9 +51,10 @@ impl KeyBind { physical_key: Option<&Physical>, ) -> bool { let key_eq = self.key_eq(key) - || physical_key - .and_then(physical_key_to_latin) - .is_some_and(|latin| self.key_eq(&latin)); + || (!is_latin_shortcut_key(key) + && physical_key + .and_then(physical_key_to_latin) + .is_some_and(|latin| self.key_eq(&latin))); key_eq && modifiers.logo() == self.modifiers.contains(&Modifier::Super) && modifiers.control() == self.modifiers.contains(&Modifier::Ctrl) @@ -70,6 +71,19 @@ impl KeyBind { } } +fn is_latin_shortcut_key(key: &Key) -> bool { + let Key::Character(s) = key else { + return false; + }; + + let mut chars = s.chars(); + let Some(ch) = chars.next() else { + return false; + }; + + chars.next().is_none() && (ch.is_ascii_graphic() || ch == ' ') +} + /// Converts a physical key code to the corresponding US-layout Latin `Key`. /// /// This mapping is intentionally limited to keys that may produce different @@ -138,3 +152,92 @@ impl fmt::Display for KeyBind { } } } + +#[cfg(test)] +mod test { + use super::*; + + fn bind_ctrl_w() -> KeyBind { + KeyBind { + modifiers: vec![Modifier::Ctrl], + key: Key::Character("w".into()), + } + } + + #[test] + fn ctrl_w() { + assert!(bind_ctrl_w().matches( + Modifiers::CTRL, + &Key::Character("w".into()), + Some(&Physical::Code(Code::KeyW)), + )); + } + + #[test] + fn ctrl_w_no_fallback_to_dvorak_comma() { + assert!(!bind_ctrl_w().matches( + Modifiers::CTRL, + &Key::Character(",".into()), + Some(&Physical::Code(Code::KeyW)), + )); + } + + #[test] + fn non_latin_layout_fallback() { + assert!(bind_ctrl_w().matches( + Modifiers::CTRL, + &Key::Character("ц".into()), + Some(&Physical::Code(Code::KeyW)), + )); + + let bind = KeyBind { + modifiers: vec![Modifier::Ctrl], + key: Key::Character("s".into()), + }; + + assert!(bind.matches( + Modifiers::CTRL, + &Key::Character("ы".into()), + Some(&Physical::Code(Code::KeyS)), + )); + + assert!(!bind.matches( + Modifiers::CTRL, + &Key::Character("ц".into()), + Some(&Physical::Code(Code::KeyQ)), + )); + } + + #[test] + fn ctrl_space() { + let bind = KeyBind { + modifiers: vec![Modifier::Ctrl], + key: Key::Character(" ".into()), + }; + + assert!(bind.matches(Modifiers::CTRL, &Key::Character(" ".into()), None,)); + } + + #[test] + fn ctrl_space_no_fallback() { + assert!(!bind_ctrl_w().matches( + Modifiers::CTRL, + &Key::Character(" ".into()), + Some(&Physical::Code(Code::KeyW)), + )); + } + + #[test] + fn ctrl_a_no_fallback_to_french_azerty_q() { + let bind = KeyBind { + modifiers: vec![Modifier::Ctrl], + key: Key::Character("a".into()), + }; + + assert!(!bind.matches( + Modifiers::CTRL, + &Key::Character("q".into()), + Some(&Physical::Code(Code::KeyA)), + )); + } +} diff --git a/src/widget/progress_bar/circular.rs b/src/widget/progress_bar/circular.rs index 3a3fedc4..3318d7a3 100644 --- a/src/widget/progress_bar/circular.rs +++ b/src/widget/progress_bar/circular.rs @@ -9,7 +9,8 @@ use iced::{Element, Event, Length, Radians, Rectangle, Renderer, Size, Vector, m use std::f32::consts::PI; use std::time::Duration; -const MIN_ANGLE: Radians = Radians(PI / 8.0); +const MIN_GAP_ANGLE: Radians = Radians(PI / 4.0); +const MAX_WRAP: f32 = 1.0 - MIN_GAP_ANGLE.0 / (2.0 * PI); #[must_use] pub struct Circular @@ -76,12 +77,6 @@ where self.progress = Some(progress.clamp(0.0, 1.0)); self } - - fn min_wrap(&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), 1.0 - gap / PI) - } } impl Default for Circular @@ -148,11 +143,12 @@ where shell.request_redraw(); } } else { - let (_, wrap) = self.min_wrap(self.size / 2.0 - self.bar_height); - state.animation = - state - .animation - .timed_transition(self.cycle_duration, self.period, wrap, *now); + state.animation = state.animation.timed_transition( + self.cycle_duration, + self.period, + MAX_WRAP, + *now, + ); state.cache.clear(); shell.request_redraw(); } @@ -191,25 +187,6 @@ where // Converts a track fraction to an angle in radians, with 0 being top of circle let to_angle = |t: f32| t * 2.0 * PI - PI / 2.0; - - let draw_cap = |frame: &mut canvas::Frame, t: f32, flip: bool| { - let angle = to_angle(t); - let center = frame.center() + Vector::new(angle.cos(), angle.sin()) * track_radius; - let (start_angle, end_angle) = if flip { - (angle - PI, angle) - } else { - (angle, angle + PI) - }; - let mut builder = canvas::path::Builder::new(); - builder.arc(canvas::path::Arc { - center, - radius: self.bar_height / 2.0, - start_angle: Radians(start_angle), - end_angle: Radians(end_angle), - }); - frame.fill(&builder.build(), custom_style.bar_color); - }; - let draw_bar = |frame: &mut canvas::Frame, start: f32, end: f32| { let mut builder = canvas::path::Builder::new(); builder.arc(canvas::path::Arc { @@ -222,10 +199,9 @@ where &builder.build(), canvas::Stroke::default() .with_color(custom_style.bar_color) - .with_width(self.bar_height), + .with_width(self.bar_height) + .with_line_cap(canvas::LineCap::Round), ); - draw_cap(frame, end, false); - draw_cap(frame, start, true); }; if self.progress.is_some() { @@ -243,10 +219,11 @@ where } draw_bar(frame, 0.0, state.progress.current); } else { - let (min, wrap) = self.min_wrap(track_radius); - let (start, end) = state - .animation - .bar_positions(self.cycle_duration, min, wrap); + // f32::EPSILON prevents flicker when wrap angle is 0.0 + let (start, end) = + state + .animation + .bar_positions(self.cycle_duration, f32::EPSILON, MAX_WRAP); draw_bar(frame, start, end); } }); diff --git a/src/widget/progress_bar/linear.rs b/src/widget/progress_bar/linear.rs index 0ebe402f..b8e92453 100644 --- a/src/widget/progress_bar/linear.rs +++ b/src/widget/progress_bar/linear.rs @@ -3,7 +3,7 @@ use super::animation::{Animation, Progress}; use super::style::StyleSheet; use iced::advanced::widget::tree::{self, Tree}; use iced::advanced::{self, Clipboard, Layout, Shell, Widget, layout, renderer}; -use iced::{Background, Element, Event, Length, Pixels, Rectangle, Size, mouse, window}; +use iced::{Element, Event, Length, Pixels, Rectangle, Size, mouse, window}; use std::time::Duration; @@ -39,7 +39,7 @@ where period: Duration::from_secs(2), progress: None, markers: Vec::new(), - segment_spacing: 0.0, + segment_spacing: 1.0, } } @@ -81,11 +81,11 @@ where } /// Sets the markers of a determinate progress bar, which divide the bar into segments. - /// Each value is a progress fraction between `0.0` and `1.0 at which a visual gap is inserted. + /// Each marker is a value between `0.0` and `1.0` that defines the position of a visual gap. pub fn markers(mut self, markers: impl Into>) -> Self { let mut markers = markers.into(); - for bp in &mut markers { - *bp = bp.clamp(0.0, 1.0); + for marker in &mut markers { + *marker = marker.clamp(0.0, 1.0); } markers.sort_by(f32::total_cmp); markers.dedup(); @@ -96,7 +96,7 @@ where /// Sets the spacing between segments at each marker. pub fn segment_spacing(mut self, spacing: impl Into) -> Self { - self.segment_spacing = spacing.into().0; + self.segment_spacing = spacing.into().0.max(1.0); self } } @@ -197,110 +197,144 @@ where let border_color = custom_style.border_color.unwrap_or(custom_style.bar_color); let radius = custom_style.border_radius; - let mut draw_quad = |x: f32, width: f32, color: iced::Color, border: iced::Border| { - // don't draw if width is less than 0.1 pixels - if width * bounds.width > 0.1 { - renderer.fill_quad( - renderer::Quad { - bounds: Rectangle { - x: bounds.x + x * bounds.width, - y: bounds.y, - width: width * bounds.width, - height: bounds.height, - }, - border, - snap: true, - ..renderer::Quad::default() - }, - Background::Color(color), - ); + let mut draw_quad = |x: f32, + width: f32, + color: iced::Color, + mut border: iced::Border, + is_track: bool, + total_progress_width: f32| { + let mut height = bounds.height; + if !is_track { + // For progress that is at the end of completion + if total_progress_width > bounds.width - radius { + let border_radius = + radius.min(bounds.height / 2.0) - (bounds.width - total_progress_width); + border.radius.top_right = border_radius; + border.radius.bottom_right = border_radius; + } else { + border.radius.top_right = 0.0; + border.radius.bottom_right = 0.0; + } + + // For indeterminate mode or when progress has just started + if x < radius.min(bounds.height / 2.0) { + let border_radius = radius.min(bounds.height / 2.0) - x; + border.radius.top_left = border_radius; + border.radius.bottom_left = border_radius; + + if total_progress_width < radius.min(bounds.height / 2.0) { + height = bounds.height - 2.0 * radius.min(bounds.height / 2.0) + + total_progress_width * 2.0; + } + } else { + border.radius.top_left = 0.0; + border.radius.bottom_left = 0.0; + } + + if x > bounds.width - radius.min(bounds.height / 2.0) { + height = bounds.height - 2.0 * radius.min(bounds.height / 2.0) + width * 2.0; + } } + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x + x, + y: bounds.y + (bounds.height - height) / 2.0, + width, + height, + }, + border, + snap: true, + ..renderer::Quad::default() + }, + color, + ); }; if self.progress.is_some() { - let spacing = self.segment_spacing.max(1.0); + let current_p = state.progress.current; + let len = self.markers.len(); + let spacing = self.segment_spacing; let radius_inner = radius.min(spacing); - let gap = if self.markers.is_empty() { - 0.0 - } else { + let gap = if len != 0 { spacing / bounds.width + } else { + 0.0 }; - let drawable = 1.0 - gap * self.markers.len() as f32; - let num_segments = self.markers.len() + 1; + let drawable = 1.0 - gap * len as f32; - let segment_bounds = |i: usize| { - let seg_lo = if i == 0 { 0.0 } else { self.markers[i - 1] }; - let seg_hi = if i == num_segments - 1 { - 1.0 + let mut absolute_width = 0.0; + for i in 0..=len { + let (seg_lo, r_left) = if i == 0 { + (0.0, radius) } else { - self.markers[i] + (self.markers[i - 1], radius_inner) }; - (seg_lo, seg_hi) - }; - let get_radius = |i: usize| { - let r_left = if i == 0 { radius } else { radius_inner }; - let r_right = if i == num_segments - 1 { - radius + let (seg_hi, r_right) = if i == len { + (1.0, radius) } else { - radius_inner + (self.markers[i], radius_inner) }; - [r_left, r_right, r_right, r_left].into() - }; - - // draw track segments - for i in 0..num_segments { - let (seg_lo, seg_hi) = segment_bounds(i); let x_start = seg_lo * drawable + i as f32 * gap; let x_width = (seg_hi - seg_lo) * drawable; + let mut segment_radius = if i == 0 && len == 0 { + [r_left, r_right, r_right, r_left].into() + } else if i == 0 { + [r_left, 0.0, 0.0, r_left].into() + } else if i == len { + [0.0, r_right, r_right, 0.0].into() + } else { + [0.0, 0.0, 0.0, 0.0].into() + }; + + // draw track segment draw_quad( - x_start, - x_width, + x_start * bounds.width, + x_width * bounds.width, custom_style.track_color, iced::Border { width: border_width, color: border_color, - radius: get_radius(i), + radius: segment_radius, }, + true, + bounds.width, ); - } - // draw bar segments - let current_p = state.progress.current; - for i in 0..num_segments { - let (seg_lo, seg_hi) = segment_bounds(i); - - // don't iterate over non-filled segments - if current_p < seg_lo { - break; + // draw bar segment + if current_p > seg_lo { + let fill = ((current_p - seg_lo) / (seg_hi - seg_lo)).min(1.0); + absolute_width += x_width * fill + if i == 0 { 0.0 } else { gap }; + segment_radius = [r_left, r_right, r_right, r_left].into(); + draw_quad( + x_start * bounds.width, + x_width * fill * bounds.width, + custom_style.bar_color, + iced::Border { + radius: segment_radius, + ..iced::Border::default() + }, + false, + absolute_width * bounds.width, + ); } - - let x_start = seg_lo * drawable + i as f32 * gap; - let x_width = (seg_hi - seg_lo) * drawable; - let fill = ((current_p - seg_lo) / (seg_hi - seg_lo)).clamp(0.0, 1.0); - - draw_quad( - x_start, - x_width * fill, - custom_style.bar_color, - iced::Border { - radius: get_radius(i), - ..iced::Border::default() - }, - ); } } else { // draw track draw_quad( 0.0, - 1.0, + bounds.width, custom_style.track_color, iced::Border { width: border_width, color: border_color, radius: radius.into(), }, + true, + bounds.width, ); // draw bar @@ -312,24 +346,26 @@ where let start = bar_start % 1.0; let right_width = (1.0 - start).min(length); let left_width = length - right_width; + let border = iced::Border { + radius: radius.into(), + ..iced::Border::default() + }; draw_quad( - start, - right_width, + start * bounds.width, + right_width * bounds.width, custom_style.bar_color, - iced::Border { - radius: radius.into(), - ..iced::Border::default() - }, + border, + false, + (right_width + start) * bounds.width, ); draw_quad( 0.0, - left_width, + left_width * bounds.width, custom_style.bar_color, - iced::Border { - radius: radius.into(), - ..iced::Border::default() - }, + border, + false, + left_width * bounds.width, ); } }