diff --git a/Cargo.lock b/Cargo.lock index 27450fd..c7a29a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2355,6 +2355,7 @@ dependencies = [ "cosmic-client-toolkit", "dnd", "glam", + "iced_accessibility", "lilt", "log", "mime 0.1.0", @@ -2445,6 +2446,7 @@ dependencies = [ "bytes", "cosmic-client-toolkit", "dnd", + "iced_accessibility", "iced_core", "iced_futures", "raw-window-handle", @@ -2509,6 +2511,7 @@ dependencies = [ "build_helpers", "cosmic-client-toolkit", "dnd", + "iced_accessibility", "iced_renderer", "iced_runtime", "log", @@ -2529,6 +2532,7 @@ dependencies = [ "cosmic-client-toolkit", "cursor-icon", "dnd", + "iced_accessibility", "iced_debug", "iced_futures", "iced_graphics", @@ -3030,6 +3034,7 @@ dependencies = [ "i18n-embed", "i18n-embed-fl", "iced", + "iced_accessibility", "iced_core", "iced_futures", "iced_renderer", diff --git a/README.md b/README.md index efabb7f..63c4e8a 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,8 @@ target/release/ferese --backend nested --grant-effects --grant-shell-control -- ``` The Ferese mark enters overview, where workspace indicators become available. -The focused app appears on the left; status controls and day/time sit on the +The active workspace's window list appears on the left, with click-to-focus +and a highlighted active window; status controls and day/time sit on the right. The 28px bar has square screen edges, 19px icons, and no border or drop shadow. Window spacing uses the workspace outer gap; `top_bar_window_gap` under `[theme.geometry]` adds optional extra space (default 0). Solid and translucent diff --git a/docs/installation.md b/docs/installation.md index 3bff658..2f84d8e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,26 @@ # Fedora / SDDM installation -Build and test in the checkout: +From the checkout, run the installer as your normal user: + +```sh +./scripts/install.sh +``` + +It builds all three release binaries with the locked dependencies, then uses +`sudo` (or `pkexec`) for installation. Install the Rust toolchain, native build +dependencies, `desktop-file-utils`, and D-Bus first. Cargo reports missing native +libraries during the build. The shell requires Rust 1.93 or newer. + +Useful options: + +```sh +./scripts/install.sh --dry-run +./scripts/install.sh --offline --release-id my-demo +./scripts/install.sh --skip-build --release-id existing-build +``` + +The script works from any directory and preserves your user configuration. +Use `--help` for all options. To test and install manually: ```sh cargo test -p ferese -p feresectl -p ferese-animation -p ferese-core -p ferese-layout -p ferese-shell --locked diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..27c7421 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Build as the invoking user; elevate only the versioned system installation. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/install.sh [options] + +Build and install Ferese's compositor, shell, CLI, and login-screen session. +Run from any directory as your normal user. Existing user config is preserved. + + --release-id ID Name the installed release (default: UTC timestamp + PID) + --skip-build Install existing target/release binaries + --offline Build using only cached Cargo dependencies + --dry-run Print commands without building or installing + -h, --help Show this help + +The previous release remains available for rollback. Administrator +authentication is requested only after a successful build. +EOF +} + +fail() { echo "ferese installer: $*" >&2; exit 1; } +repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +release_id="$(date -u +%Y%m%d-%H%M%S)-$$" +skip_build=false +offline=false +dry_run=false +while (($#)); do + case $1 in + --release-id) + (($# >= 2)) || fail '--release-id requires a value' + release_id=$2 + shift 2 + ;; + --skip-build) skip_build=true; shift ;; + --offline) offline=true; shift ;; + --dry-run) dry_run=true; shift ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1 (see --help)" ;; + esac +done +[[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || fail 'invalid release ID' +[[ $(uname -s) == Linux ]] || fail 'Ferese requires Linux' +for tool in desktop-file-validate dbus-run-session; do + command -v "$tool" >/dev/null || fail "required command not found: $tool" +done +desktop-file-validate "$repo_dir/packaging/ferese.desktop" + +build=(cargo build --manifest-path "$repo_dir/Cargo.toml" + --target-dir "$repo_dir/target" --release --locked + -p ferese -p ferese-shell -p feresectl) +if $offline; then build+=(--offline); fi +if ! $skip_build; then + ((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)' + command -v cargo >/dev/null || fail 'install the Rust toolchain and Cargo first' +fi + +installer=(bash "$repo_dir/scripts/install-session.sh" "$release_id") +if ((EUID != 0)); then + if command -v sudo >/dev/null; then + installer=(sudo "${installer[@]}") + elif command -v pkexec >/dev/null; then + installer=(pkexec /usr/bin/bash "$repo_dir/scripts/install-session.sh" "$release_id") + else + fail 'system installation requires sudo or pkexec' + fi +fi + +if $dry_run; then + if ! $skip_build; then printf 'Build: '; printf '%q ' "${build[@]}"; printf '\n'; fi + printf 'Install: '; printf '%q ' "${installer[@]}"; printf '\n' + exit 0 +fi + +cd -- "$repo_dir" +if ! $skip_build; then "${build[@]}"; fi +for name in ferese ferese-shell feresectl; do + [[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name" +done +"${installer[@]}" +echo 'Installation complete. Log out and select Ferese at the login screen.' diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 6f44e7a..fe4811e 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -10,7 +10,7 @@ ferese-protocols = { path = "../../crates/ferese-protocols", default-features = jiff.workspace = true # Keep GPU rendering available as a fallback/override. The shell defaults to # tiny-skia to avoid a second GPU initialization during desktop startup. -libcosmic = { workspace = true, features = ["wgpu"] } +libcosmic = { workspace = true, features = ["wgpu", "a11y"] } serde.workspace = true toml.workspace = true wayland-client.workspace = true diff --git a/shell/ferese-shell/assets/icons/status/battery-25.svg b/shell/ferese-shell/assets/icons/status/battery-25.svg index 75774e1..cf4f108 100644 --- a/shell/ferese-shell/assets/icons/status/battery-25.svg +++ b/shell/ferese-shell/assets/icons/status/battery-25.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-50.svg b/shell/ferese-shell/assets/icons/status/battery-50.svg index ad74dd0..f139f8b 100644 --- a/shell/ferese-shell/assets/icons/status/battery-50.svg +++ b/shell/ferese-shell/assets/icons/status/battery-50.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-75.svg b/shell/ferese-shell/assets/icons/status/battery-75.svg index 25168ef..29c431d 100644 --- a/shell/ferese-shell/assets/icons/status/battery-75.svg +++ b/shell/ferese-shell/assets/icons/status/battery-75.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-charging.svg b/shell/ferese-shell/assets/icons/status/battery-charging.svg index a4b3f05..e1cea6a 100644 --- a/shell/ferese-shell/assets/icons/status/battery-charging.svg +++ b/shell/ferese-shell/assets/icons/status/battery-charging.svg @@ -1,5 +1,6 @@ - - - - + + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-empty.svg b/shell/ferese-shell/assets/icons/status/battery-empty.svg index 0a88b4c..aa571f4 100644 --- a/shell/ferese-shell/assets/icons/status/battery-empty.svg +++ b/shell/ferese-shell/assets/icons/status/battery-empty.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-full.svg b/shell/ferese-shell/assets/icons/status/battery-full.svg index 9d5e5fd..c37c3b6 100644 --- a/shell/ferese-shell/assets/icons/status/battery-full.svg +++ b/shell/ferese-shell/assets/icons/status/battery-full.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-connected.svg b/shell/ferese-shell/assets/icons/status/bluetooth-connected.svg index 81b3302..9abd5d9 100644 --- a/shell/ferese-shell/assets/icons/status/bluetooth-connected.svg +++ b/shell/ferese-shell/assets/icons/status/bluetooth-connected.svg @@ -1,5 +1,4 @@ - - - - + + + diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-off.svg b/shell/ferese-shell/assets/icons/status/bluetooth-off.svg index e228222..c326264 100644 --- a/shell/ferese-shell/assets/icons/status/bluetooth-off.svg +++ b/shell/ferese-shell/assets/icons/status/bluetooth-off.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-on.svg b/shell/ferese-shell/assets/icons/status/bluetooth-on.svg index bbfff7f..6471126 100644 --- a/shell/ferese-shell/assets/icons/status/bluetooth-on.svg +++ b/shell/ferese-shell/assets/icons/status/bluetooth-on.svg @@ -1,3 +1,3 @@ - - + + diff --git a/shell/ferese-shell/assets/icons/status/control-center.svg b/shell/ferese-shell/assets/icons/status/control-center.svg index b5b9603..ad12fa4 100644 --- a/shell/ferese-shell/assets/icons/status/control-center.svg +++ b/shell/ferese-shell/assets/icons/status/control-center.svg @@ -1,6 +1,8 @@ - - - - - + + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/notifications-off.svg b/shell/ferese-shell/assets/icons/status/notifications-off.svg index 158cb71..da7abb3 100644 --- a/shell/ferese-shell/assets/icons/status/notifications-off.svg +++ b/shell/ferese-shell/assets/icons/status/notifications-off.svg @@ -1,4 +1,8 @@ - - - + + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/notifications.svg b/shell/ferese-shell/assets/icons/status/notifications.svg index 9c5deaa..b422aa2 100644 --- a/shell/ferese-shell/assets/icons/status/notifications.svg +++ b/shell/ferese-shell/assets/icons/status/notifications.svg @@ -1,4 +1,7 @@ - - - + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/volume-high.svg b/shell/ferese-shell/assets/icons/status/volume-high.svg index 2cf5669..ff837ba 100644 --- a/shell/ferese-shell/assets/icons/status/volume-high.svg +++ b/shell/ferese-shell/assets/icons/status/volume-high.svg @@ -1,5 +1,7 @@ - - - - + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/volume-low.svg b/shell/ferese-shell/assets/icons/status/volume-low.svg index f07b4d1..c193e1d 100644 --- a/shell/ferese-shell/assets/icons/status/volume-low.svg +++ b/shell/ferese-shell/assets/icons/status/volume-low.svg @@ -1,5 +1,7 @@ - - - - + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/volume-medium.svg b/shell/ferese-shell/assets/icons/status/volume-medium.svg index 036c799..7ca2e88 100644 --- a/shell/ferese-shell/assets/icons/status/volume-medium.svg +++ b/shell/ferese-shell/assets/icons/status/volume-medium.svg @@ -1,5 +1,7 @@ - - - - + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/volume-mute.svg b/shell/ferese-shell/assets/icons/status/volume-mute.svg index ff803e8..a489437 100644 --- a/shell/ferese-shell/assets/icons/status/volume-mute.svg +++ b/shell/ferese-shell/assets/icons/status/volume-mute.svg @@ -1,4 +1,6 @@ - - - + + + + + diff --git a/shell/ferese-shell/assets/icons/status/wifi-full.svg b/shell/ferese-shell/assets/icons/status/wifi-full.svg index 8d83c72..444af53 100644 --- a/shell/ferese-shell/assets/icons/status/wifi-full.svg +++ b/shell/ferese-shell/assets/icons/status/wifi-full.svg @@ -1,6 +1,8 @@ - - - - - + + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/wifi-low.svg b/shell/ferese-shell/assets/icons/status/wifi-low.svg index b37e417..a8d5adf 100644 --- a/shell/ferese-shell/assets/icons/status/wifi-low.svg +++ b/shell/ferese-shell/assets/icons/status/wifi-low.svg @@ -1,6 +1,8 @@ - - - - - + + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/wifi-medium.svg b/shell/ferese-shell/assets/icons/status/wifi-medium.svg index f50e12a..9d48fdc 100644 --- a/shell/ferese-shell/assets/icons/status/wifi-medium.svg +++ b/shell/ferese-shell/assets/icons/status/wifi-medium.svg @@ -1,6 +1,8 @@ - - - - - + + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/wifi-off.svg b/shell/ferese-shell/assets/icons/status/wifi-off.svg index 9f7571c..116b907 100644 --- a/shell/ferese-shell/assets/icons/status/wifi-off.svg +++ b/shell/ferese-shell/assets/icons/status/wifi-off.svg @@ -1,5 +1,9 @@ - - - - + + + + + + + + diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index a7ad319..8aedd7f 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -41,6 +41,7 @@ pub(crate) struct WorkspaceSnapshot { #[derive(Clone, Debug)] pub(crate) struct WindowSnapshot { + pub(crate) id: u64, pub(crate) workspace: u64, pub(crate) app_id: String, pub(crate) title: String, @@ -120,6 +121,12 @@ impl ShellControl { let _ = self.connection.flush(); } + pub(crate) fn activate_window(&self, id: u64) { + let (hi, lo) = split_id(id); + self.shell.activate_window(hi, lo); + let _ = self.connection.flush(); + } + pub(crate) fn set_overview_active(&self, active: bool) { if active { self.shell.enter_overview(); @@ -218,8 +225,8 @@ impl Dispatch for ControlState { }); } ferese_shell_v1::Event::Window { - window_hi: _, - window_lo: _, + window_hi, + window_lo, workspace_hi, workspace_lo, app_id, @@ -235,6 +242,7 @@ impl Dispatch for ControlState { }; state.pending.windows.push(WindowSnapshot { + id: join_id(window_hi, window_lo), workspace: join_id(workspace_hi, workspace_lo), app_id, title, diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 464074e..014e779 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -144,6 +144,7 @@ enum Message { ), Tick, ActivateWorkspace(u64), + ActivateWindow(u64), ToggleOverview, StatusTick, AnimateMenu, @@ -383,6 +384,12 @@ impl cosmic::Application for FereseShell { } Task::none() } + Message::ActivateWindow(id) => { + if let Some(control) = &self.control { + control.activate_window(id); + } + Task::none() + } Message::ActivateWorkspace(id) => { if let Some(control) = &self.control { control.activate_workspace(id); @@ -597,34 +604,43 @@ impl FereseShell { workspace_row = workspace_row.push(workspace_button); } - let app_name = self - .snapshot - .windows - .iter() - .find(|window| { - window.focused - && focused_output - .is_none_or(|output| window.workspace == output.active_workspace) - }) - .map(display_app_name) - .unwrap_or_else(|| "Desktop".to_owned()); let foreground = color(shell_theme.text_primary); - let app_name = if self.overview_active { - "Overview".to_owned() - } else { - app_name - }; - let left = row![ - workspace_row, - text(app_name) - .size(bar.text_size) - .font(cosmic::font::semibold()) - .width(Length::Fill) - .wrapping(cosmic::iced::core::text::Wrapping::None) - .class(theme::Text::Color(foreground)) - ] - .spacing(8) - .align_y(cosmic::iced::Alignment::Center); + let mut windows = row::with_capacity(self.snapshot.windows.len()) + .spacing(4) + .align_y(cosmic::iced::Alignment::Center); + for window in self.snapshot.windows.iter().filter(|window| { + focused_output.is_some_and(|output| window.workspace == output.active_workspace) + }) { + let label = compact_window_label(window); + let foreground = color(if window.focused { + shell_theme.text_primary + } else { + shell_theme.text_muted + }); + windows = windows.push( + button::custom( + text(label) + .size(bar.text_size) + .wrapping(cosmic::iced::core::text::Wrapping::None) + .class(theme::Text::Color(foreground)), + ) + .padding([0, 8]) + .height(bar.control_height) + .class(status_ui::button_style(foreground, window.focused, 1.0)) + .on_press(cosmic::Action::App(Message::ActivateWindow(window.id))), + ); + } + let windows = cosmic::iced::widget::scrollable(windows) + .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( + cosmic::iced::widget::scrollable::Scrollbar::default() + .width(0) + .scroller_width(0), + )) + .width(Length::Fill) + .height(bar.control_height); + let left = row![workspace_row, windows] + .spacing(8) + .align_y(cosmic::iced::Alignment::Center); let clock = container( text(&self.clock) .size(bar.text_size) @@ -680,21 +696,47 @@ impl FereseShell { } } -fn symbolic_icon(source: &'static [u8], size: u16) -> icon::Icon { - icon::from_svg_bytes(source) - .symbolic(true) - .icon() - .size(size) +fn bar_icon(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon { + accented_icon(source, size, foreground, foreground) } -fn bar_icon(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon { - // COSMIC's transparent containers replace inherited foreground colors. - // Pin the bar glyph color rather than relying on those container defaults. - symbolic_icon(source, size).class(theme::Svg::custom(move |_| { - cosmic::iced::widget::svg::Style { - color: Some(foreground), - } - })) +fn accented_icon(source: &'static [u8], size: u16, foreground: Color, accent: Color) -> icon::Icon { + let svg = std::str::from_utf8(source).expect("embedded SVG must be UTF-8"); + // Battery canvases are wider; reserve that space instead of stretching + // or clipping them into the square slot used by the other status icons. + let aspect = if svg.contains("viewBox=\"0 0 32 24\"") { + 4.0 / 3.0 + } else { + 1.0 + }; + icon::from_svg_bytes(tinted_svg(source, foreground, accent)) + .symbolic(false) + .icon() + .size(size) + .width(Length::Fixed(f32::from(size) * aspect)) + .content_fit(ContentFit::Contain) + .class(theme::Svg::custom(move |_| { + cosmic::iced::widget::svg::Style { color: None } + })) +} + +fn tinted_svg(source: &[u8], foreground: Color, accent: Color) -> Vec { + let rgba = |foreground: Color| { + format!( + "rgba({},{},{},{})", + (foreground.r * 255.0).round() as u8, + (foreground.g * 255.0).round() as u8, + (foreground.b * 255.0).round() as u8, + foreground.a, + ) + }; + std::str::from_utf8(source) + .expect("embedded icons must be UTF-8 SVG") + .replace("currentColor", &rgba(foreground)) + // The source artwork's blue swatch is the semantic theme accent. + // Battery warning/success swatches remain status colors. + .replace("#3d7be6", &rgba(accent)) + .into_bytes() } fn overview_control( @@ -806,6 +848,22 @@ fn current_time() -> String { Zoned::now().strftime("%a · %H:%M").to_string() } +fn compact_window_label(window: &crate::control::WindowSnapshot) -> String { + let title = window.title.trim(); + let label = if title.is_empty() { + display_app_name(window) + } else { + title.to_owned() + }; + let mut characters = label.chars(); + let prefix: String = characters.by_ref().take(24).collect(); + if characters.next().is_some() { + format!("{prefix}…") + } else { + prefix + } +} + fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style { cosmic::iced::theme::Style { background_color: Color::TRANSPARENT, @@ -967,6 +1025,70 @@ mod tests { use super::*; use crate::control::{OutputSnapshot, WindowSnapshot}; + #[test] + fn icons_use_theme_accent_and_preserve_battery_status_colors() { + for (source, accent) in [ + ( + include_bytes!("../assets/icons/status/battery-full.svg").as_slice(), + "#63c168", + ), + ( + include_bytes!("../assets/icons/status/battery-50.svg").as_slice(), + "#3d7be6", + ), + ( + include_bytes!("../assets/icons/status/battery-25.svg").as_slice(), + "#e0654f", + ), + ( + include_bytes!("../assets/icons/status/wifi-full.svg").as_slice(), + "#3d7be6", + ), + ( + include_bytes!("../assets/icons/status/control-center.svg").as_slice(), + "#3d7be6", + ), + ] { + let svg = String::from_utf8(tinted_svg( + source, + Color::from_rgb8(205, 214, 244), + Color::from_rgb8(203, 166, 247), + )) + .unwrap(); + assert!(!svg.contains("currentColor")); + assert!(svg.contains("rgba(205,214,244,1)")); + assert!(!svg.contains("#3d7be6")); + assert!(svg.contains(if accent == "#3d7be6" { + "rgba(203,166,247,1)" + } else { + accent + })); + } + } + + #[test] + fn window_list_labels_use_titles_and_fall_back_to_the_app_name() { + let snapshot = snapshot_with_fullscreen_window(7); + let mut window = snapshot.windows[0].clone(); + window.title = " editor.rs — Project ".to_owned(); + assert_eq!(compact_window_label(&window), "editor.rs — Project"); + window.title = " ".to_owned(); + assert_eq!(compact_window_label(&window), display_app_name(&window)); + } + + #[test] + fn window_list_labels_truncate_unicode_without_breaking_characters() { + let snapshot = snapshot_with_fullscreen_window(7); + let mut window = snapshot.windows[0].clone(); + window.title = "界".repeat(25); + assert_eq!( + compact_window_label(&window), + format!("{}…", "界".repeat(24)) + ); + window.title = "界".repeat(24); + assert_eq!(compact_window_label(&window), window.title); + } + #[test] fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() { let theme = ShellTheme::default(); @@ -1048,6 +1170,7 @@ mod tests { }], workspaces: Vec::new(), windows: vec![WindowSnapshot { + id: 1, workspace, app_id: "dev.ferese.Test".to_owned(), title: "Test".to_owned(), diff --git a/shell/ferese-shell/src/status_ui.rs b/shell/ferese-shell/src/status_ui.rs index 43c1770..ac84964 100644 --- a/shell/ferese-shell/src/status_ui.rs +++ b/shell/ferese-shell/src/status_ui.rs @@ -152,33 +152,26 @@ impl FereseShell { let metrics = BarMetrics::from(theme); let mut controls = row::with_capacity(6).spacing(3).align_y(Alignment::Center); for kind in [ + Menu::System, Menu::Network, Menu::Bluetooth, Menu::Audio, - Menu::Battery, Menu::Notifications, - Menu::System, + Menu::Battery, ] { if !kind.available(&self.status) { continue; } - let (source, active) = status_icon(kind, &self.status); - let warning = kind == Menu::Battery - && self.status.battery.as_ref().is_some_and(|b| { - b.percent < self.config.status.low_battery_threshold && b.status != "Charging" - }); - let foreground = if warning { - Color::from_rgb8(245, 193, 105) - } else { - color(if active { - theme.text_primary - } else { - theme.text_muted - }) - }; - let mut content = row![bar_icon(source, metrics.icon_size, foreground)] - .spacing(4) - .align_y(Alignment::Center); + let (source, _) = status_icon(kind, &self.status); + let foreground = color(theme.text_primary); + let mut content = row![accented_icon( + source, + metrics.icon_size, + foreground, + color(theme.accent) + )] + .spacing(4) + .align_y(Alignment::Center); if kind == Menu::Battery && self.config.status.battery_percentage { if let Some(battery) = &self.status.battery { content = content.push( @@ -210,11 +203,16 @@ impl FereseShell { // A fixed button height does not center its child in libcosmic. // Keep the visual content centered inside the entire click target. let content = container(content) - .height(metrics.control_height) + .align_x(alignment::Horizontal::Center) + .height(metrics.height) .align_y(alignment::Vertical::Center); let control = button::custom(content) - .padding([0, 7]) - .height(metrics.control_height) + .name(status_label(kind, &self.status)) + .padding([ + 0.0, + ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(7.0), + ]) + .height(metrics.height) .class(button_style(foreground, selected, 1.0)) .on_press_with_rectangle(move |offset, bounds| { cosmic::Action::App(Message::OpenMenu( @@ -365,6 +363,7 @@ impl FereseShell { }, primary, muted, + color_with_opacity(theme.accent, p), p, )); if connected { @@ -408,6 +407,7 @@ impl FereseShell { }, primary, muted, + color_with_opacity(theme.accent, p), p, )); if b.enabled && !b.devices.is_empty() { @@ -439,7 +439,14 @@ impl FereseShell { ] .align_y(Alignment::Center), text(&a.output).size(12).class(theme::Text::Color(muted)), - slider_row(audio_icon(a.volume, a.muted), a.volume, false, primary, p,) + slider_row( + audio_icon(a.volume, a.muted), + a.volume, + false, + primary, + color_with_opacity(theme.accent, p), + p, + ) ] .spacing(4); rows = rows.push(control_card(audio.into(), primary, p)); @@ -454,6 +461,7 @@ impl FereseShell { value, true, primary, + color_with_opacity(theme.accent, p), p, ) ] @@ -502,7 +510,12 @@ impl FereseShell { container( row![ text(format!("{}%", b.percent)).size(40).width(Length::Fill), - symbolic_icon(status_icon(Menu::Battery, &self.status).0, 40), + accented_icon( + status_icon(Menu::Battery, &self.status).0, + 40, + battery_color, + color_with_opacity(theme.accent, p), + ), ] .align_y(Alignment::Center), ) @@ -639,9 +652,10 @@ fn status_summary<'a>( subtitle: &'a str, primary: Color, muted: Color, + accent: Color, opacity: f32, ) -> Element<'a, cosmic::Action> { - let badge = container(symbolic_icon(source, 24)) + let badge = container(accented_icon(source, 24, primary, accent)) .width(48) .height(48) .align_x(alignment::Horizontal::Center) @@ -742,8 +756,9 @@ fn connection_control<'a>( opacity: f32, ) -> Element<'a, cosmic::Action> { let foreground = color_with_opacity(theme.text_primary, opacity); + let icon_accent = color_with_opacity(theme.accent, opacity); let accent = color_with_opacity(theme.accent, opacity * 0.14); - let icon = container(symbolic_icon(source, 18)) + let icon = container(accented_icon(source, 18, foreground, icon_accent)) .width(32) .height(32) .align_x(alignment::Horizontal::Center) @@ -850,6 +865,7 @@ fn slider_row( value: u8, brightness: bool, foreground: Color, + icon_accent: Color, opacity: f32, ) -> Element<'static, cosmic::Action> { let style = std::rc::Rc::new(move |_: &cosmic::Theme| { @@ -900,7 +916,7 @@ fn slider_row( dragging: style, }); row![ - symbolic_icon(source, 18), + accented_icon(source, 18, foreground, icon_accent), control, text(format!("{value}%")).size(12).width(36) ] @@ -909,6 +925,36 @@ fn slider_row( .into() } +fn status_label(kind: Menu, s: &Snapshot) -> String { + match kind { + Menu::Network => match &s.network { + Some(n) if n.enabled => format!( + "Wi-Fi: {}", + n.connection.as_deref().unwrap_or("not connected") + ), + _ => "Wi-Fi: off".into(), + }, + Menu::Bluetooth => match &s.bluetooth { + Some(b) if b.enabled => format!("Bluetooth: on, {} devices connected", b.devices.len()), + _ => "Bluetooth: off".into(), + }, + Menu::Audio => match &s.audio { + Some(a) if !a.muted && a.volume > 0 => format!("Audio: {}%", a.volume), + _ => "Audio: muted".into(), + }, + Menu::Notifications => match &s.notifications { + Some(n) if n.dnd => "Notifications: do not disturb".into(), + Some(n) => format!("Notifications: {} unread", n.count), + _ => "Notifications: unavailable".into(), + }, + Menu::Battery => s.battery.as_ref().map_or_else( + || "Battery: unavailable".into(), + |b| format!("Battery: {}%, {}", b.percent, b.status), + ), + Menu::System => "Control Center".into(), + } +} + fn audio_icon(volume: u8, muted: bool) -> &'static [u8] { if muted || volume == 0 { include_bytes!("../assets/icons/status/volume-mute.svg") @@ -940,7 +986,7 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { } } Menu::Bluetooth => match &s.bluetooth { - Some(b) if !b.devices.is_empty() => ( + Some(b) if b.enabled && !b.devices.is_empty() => ( include_bytes!("../assets/icons/status/bluetooth-connected.svg"), true, ), @@ -965,13 +1011,13 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { }; let icon: &'static [u8] = if b.status == "Charging" { include_bytes!("../assets/icons/status/battery-charging.svg") - } else if b.percent > 87 { + } else if b.percent >= 80 { include_bytes!("../assets/icons/status/battery-full.svg") - } else if b.percent > 62 { + } else if b.percent >= 50 { include_bytes!("../assets/icons/status/battery-75.svg") - } else if b.percent > 37 { + } else if b.percent >= 20 { include_bytes!("../assets/icons/status/battery-50.svg") - } else if b.percent > 12 { + } else if b.percent > 0 { include_bytes!("../assets/icons/status/battery-25.svg") } else { include_bytes!("../assets/icons/status/battery-empty.svg") @@ -980,7 +1026,10 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { } Menu::Notifications => { if s.notifications.as_ref().is_some_and(|n| n.dnd) { - (include_bytes!("../assets/icons/status/dnd.svg"), false) + ( + include_bytes!("../assets/icons/status/notifications-off.svg"), + false, + ) } else { ( include_bytes!("../assets/icons/status/notifications.svg"), @@ -1042,5 +1091,62 @@ mod tests { status_icon(Menu::Battery, &s).0, include_bytes!("../assets/icons/status/battery-charging.svg") ); + s.notifications = Some(status::Notifications { + count: 3, + dnd: true, + }); + assert_eq!( + status_icon(Menu::Notifications, &s).0, + include_bytes!("../assets/icons/status/notifications-off.svg") + ); + assert_eq!( + status_label(Menu::Notifications, &s), + "Notifications: do not disturb" + ); + s.bluetooth = Some(status::Bluetooth { + enabled: false, + devices: vec!["Headphones".into()], + }); + assert_eq!( + status_icon(Menu::Bluetooth, &s).0, + include_bytes!("../assets/icons/status/bluetooth-off.svg") + ); + } + + #[test] + fn battery_bands_and_live_label_follow_charge_state() { + let mut s = Snapshot::default(); + for (percent, expected) in [ + ( + 0, + include_bytes!("../assets/icons/status/battery-empty.svg").as_slice(), + ), + ( + 19, + include_bytes!("../assets/icons/status/battery-25.svg").as_slice(), + ), + ( + 20, + include_bytes!("../assets/icons/status/battery-50.svg").as_slice(), + ), + ( + 50, + include_bytes!("../assets/icons/status/battery-75.svg").as_slice(), + ), + ( + 80, + include_bytes!("../assets/icons/status/battery-full.svg").as_slice(), + ), + ] { + s.battery = Some(status::Battery { + percent, + status: "Discharging".into(), + }); + assert_eq!(status_icon(Menu::Battery, &s).0, expected); + assert_eq!( + status_label(Menu::Battery, &s), + format!("Battery: {percent}%, Discharging") + ); + } } }