Consolidate desktop controls with editable holidays and a running-window tray

This commit is contained in:
aria committed 2026-10-28 06:02:46 +09:00
1 parent 9908dde9f8
commit 326465952a
55 files changed
+4207 -450

No files matched your search

+13 -6
View File
@@ -112,6 +112,9 @@ impl FereseShell {
.iter()
.filter(|item| item.available(availability))
{
if item.kind == ItemKind::WindowTray && self.snapshot.windows.is_empty() {
continue;
}
if matches!(item.kind, ItemKind::FocusedWindow)
&& focused_bar_title(&self.snapshot, self.output_for_bar(id)).is_empty()
{
@@ -474,6 +477,12 @@ impl FereseShell {
.into();
return (container(element).max_width(360).into(), !title.is_empty());
}
ItemKind::WindowTray => {
return (
self.view_window_tray(id, &item.id.0, width, control_height),
!self.snapshot.windows.is_empty(),
);
}
ItemKind::Activities => {
return self.view_activity_item(
id,
@@ -542,7 +551,7 @@ impl FereseShell {
button::custom(bar_content(content, bar.group_item_height))
.padding([0, 4])
.height(bar.group_item_height)
.name(ferese_config::tr!("Open calendar"))
.name(ferese_config::tr!("Open calendar and controls"))
.on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::OpenMenu(
status_ui::Menu::Calendar,
@@ -1288,12 +1297,10 @@ mod island_tests {
.iter()
.map(|(id, _)| id.as_str())
.collect::<Vec<_>>(),
["quick-settings", "night-light", "recording", "clock"]
["window-tray", "recording", "clock"]
);
assert_eq!(end.items[1].1.x - end.items[0].1.x, 21.);
assert_eq!(end.items[2].1.x - end.items[1].1.x, 21.);
assert_eq!(
end.items[3].1.x - end.items[2].1.x,
end.items[2].1.x - end.items[1].1.x,
if islands { 21. } else { 34. }
);
if islands {
@@ -1301,7 +1308,7 @@ mod island_tests {
end.islands[0],
Rectangle::new(
(0., 0.).into(),
(129. + 2. * padding, panel.resolved_geometry().height).into()
(108. + 2. * padding, panel.resolved_geometry().height).into()
)
);
}
+7 -1
View File
@@ -431,7 +431,13 @@ fn parse_document(
theme_mode: mode,
panels: config
.panels
.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
.unwrap_or_else(|| vec![crate::panel::Panel::default()])
.into_iter()
.map(|mut panel| {
panel.consolidate_clock_controls();
panel
})
.collect(),
status: StatusConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status
+21
View File
@@ -30,6 +30,7 @@ mod rendering;
mod status;
mod status_ui;
mod system_modal;
mod window_tray;
mod workspace_ui;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -269,6 +270,8 @@ enum Message {
MediaArt(media::Artwork),
ControlReady,
ActivateWorkspace(u64),
ActivateWindow(u64),
HoverWindow(window::Id, u64, cosmic::iced::advanced::widget::Id, bool),
HoverWorkspace(window::Id, u64, cosmic::iced::advanced::widget::Id, bool),
WorkspaceTooltipDelay(u64),
WorkspaceTooltipBounds(u64, Option<cosmic::iced::Rectangle>),
@@ -1121,6 +1124,17 @@ impl cosmic::Application for FereseShell {
}
self.dismiss_workspace_tooltip()
}
Message::ActivateWindow(id) => {
if self.snapshot.windows.iter().any(|window| window.id == id)
&& let Some(control) = &self.control
{
control.activate_window(id);
}
self.dismiss_workspace_tooltip()
}
Message::HoverWindow(bar, window, target, entered) => {
self.hover_window(bar, window, target, entered)
}
Message::HoverWorkspace(bar, workspace, target, entered) => {
self.hover_workspace(bar, workspace, target, entered)
}
@@ -1141,6 +1155,13 @@ impl cosmic::Application for FereseShell {
if let Some(control) = &self.control {
control.set_background_title_updates(
self.launcher.is_some()
|| self.config.panels.iter().any(|panel| {
[&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.any(|item| item.visible && item.kind == panel::ItemKind::WindowTray)
})
|| self.outputs.iter().any(|output| {
output.runtime.hub.as_ref().is_some_and(|hub| {
hub.borrow().hub.mode()
+33 -17
View File
@@ -112,10 +112,19 @@ fn icon_well<'a>(
fn age_label(age: Duration) -> String {
match age.as_secs() {
0..60 => "now".into(),
60..3600 => format!("{}m ago", age.as_secs() / 60),
3600..86400 => format!("{}h ago", age.as_secs() / 3600),
seconds => format!("{}d ago", seconds / 86400),
0..60 => ferese_config::locale::translate("now"),
60..3600 => ferese_config::locale::format(
"{count}m ago",
&[("count", (age.as_secs() / 60).to_string())],
),
3600..86400 => ferese_config::locale::format(
"{count}h ago",
&[("count", (age.as_secs() / 3600).to_string())],
),
seconds => ferese_config::locale::format(
"{count}d ago",
&[("count", (seconds / 86400).to_string())],
),
}
}
@@ -706,17 +715,20 @@ impl FereseShell {
let groups = self.notifications.history_groups();
let count = self.notifications.entries.len();
let subtitle = if count == 0 {
"A quiet moment".to_owned()
ferese_config::locale::translate("A quiet moment")
} else {
format!(
"{count} {} · {} {}",
if count == 1 {
"notification"
} else {
"notifications"
},
groups.len(),
if groups.len() == 1 { "app" } else { "apps" }
let key = match (count == 1, groups.len() == 1) {
(true, true) => "{count} notification · {apps} app",
(true, false) => "{count} notification · {apps} apps",
(false, true) => "{count} notifications · {apps} app",
(false, false) => "{count} notifications · {apps} apps",
};
ferese_config::locale::format(
key,
&[
("count", count.to_string()),
("apps", groups.len().to_string()),
],
)
};
let heading = column([])
@@ -775,9 +787,9 @@ impl FereseShell {
.push(
self.render
.text(if self.notifications.dnd {
"Popups paused"
ferese_config::tr!("Popups paused")
} else {
"Popups enabled"
ferese_config::tr!("Popups enabled")
})
.size(11)
.class(theme::Text::Color(muted)),
@@ -790,7 +802,11 @@ impl FereseShell {
button::custom(
container(
self.render
.text(if self.notifications.dnd { "On" } else { "Off" })
.text(if self.notifications.dnd {
ferese_config::tr!("On")
} else {
ferese_config::tr!("Off")
})
.size(12)
.font(bold)
.class(theme::Text::Color(dnd_color)),
+4 -5
View File
@@ -1090,7 +1090,6 @@ impl FereseShell {
.width(Length::Fill);
let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center);
let mut tabs = tabs.push(navigation);
tabs = tabs.push(self.hub_shortcut_button(id, ItemKind::NightLight, menu, opacity));
if let Some(battery) =
self.hub_battery(id, menu == Some(status_ui::Menu::Battery), opacity)
{
@@ -3447,9 +3446,9 @@ mod tests {
let mut shell = shell();
let id = shell.outputs[0].bar;
for (playback, counts) in [
(None, [0, 2, 2]),
(Some(ferese_ipc::media::Playback::Playing), [0, 2, 2]),
(Some(ferese_ipc::media::Playback::Paused), [0, 2, 2]),
(None, [0, 1, 1]),
(Some(ferese_ipc::media::Playback::Playing), [0, 1, 1]),
(Some(ferese_ipc::media::Playback::Paused), [0, 1, 1]),
] {
std::sync::Arc::make_mut(&mut shell.media.snapshot).selected =
playback.map(|status| ferese_ipc::media::Player {
@@ -3462,7 +3461,7 @@ mod tests {
assert_eq!(shortcuts.len(), count);
assert!(!shortcuts.contains(&ItemKind::NightLight));
assert!(!shortcuts.contains(&ItemKind::Network));
assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 1);
assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 0);
assert!(!shortcuts.contains(&ItemKind::DisplayMode));
assert!(!shortcuts.contains(&ItemKind::Audio));
assert!(
+7
View File
@@ -21,6 +21,13 @@ impl Rendering {
self.radius.min(limit)
}
pub fn with_radius_limit(self, limit: f32) -> Self {
Self {
radius: self.radius(limit),
..self
}
}
pub fn text<'a>(
self,
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
+27 -5
View File
@@ -563,11 +563,8 @@ fn audio() -> Option<Audio> {
let (volume, muted) =
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
let info = run("wpctl", &["inspect", "@DEFAULT_AUDIO_SINK@"]).ok()?;
let output = info
.lines()
.find_map(|line| line.trim().strip_prefix("node.description = "))
.map(|s| s.trim_matches('"').to_owned())
.unwrap_or_else(|| "Default output".into());
let output = audio_output_name(&info)
.unwrap_or_else(|| ferese_config::locale::translate("Default output"));
Some(Audio {
volume,
@@ -576,6 +573,17 @@ fn audio() -> Option<Audio> {
})
}
fn audio_output_name(info: &str) -> Option<String> {
info.lines()
.find_map(|line| {
line.trim()
.trim_start_matches('*')
.trim_start()
.strip_prefix("node.description = ")
})
.map(|s| s.trim_matches('"').to_owned())
}
fn read(path: &Path, name: &str) -> Option<String> {
fs::read_to_string(path.join(name))
.ok()
@@ -1050,6 +1058,20 @@ mod tests {
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap(), None);
worker.join().unwrap();
}
#[test]
fn audio_device_name_accepts_wpctl_marked_and_unmarked_properties() {
for info in [
" * node.description = \"USB Audio S/PDIF Output\"",
" node.description = \"USB Audio S/PDIF Output\"",
] {
assert_eq!(
audio_output_name(info).as_deref(),
Some("USB Audio S/PDIF Output")
);
}
assert_eq!(audio_output_name("node.name = \"unrelated\""), None);
}
#[test]
fn audio_parser_handles_mute_and_rejects_invalid_values() {
assert_eq!(parse_audio("Volume: 0.42 [MUTED]"), Some((42, true)));
+28 -8
View File
@@ -30,14 +30,22 @@ pub(super) fn view<'a>(
]
.align_y(Alignment::Center);
if combined {
volume_heading = volume_heading.push(shell_switch(
shell.render,
"Mute",
a.muted,
Action::Mute(!a.muted),
theme,
p,
));
volume_heading = volume_heading
.push(
shell
.render
.text(ferese_config::tr!("Mute"))
.size(11)
.class(theme::Text::Color(muted)),
)
.push(shell_switch(
shell.render,
"Mute",
a.muted,
Action::Mute(!a.muted),
theme,
p,
));
}
let audio = column![
volume_heading,
@@ -63,6 +71,18 @@ pub(super) fn view<'a>(
audio.into()
};
rows = rows.push(audio);
} else {
rows = rows.push(
column![
shell.render.text(ferese_config::tr!("Volume")).size(13),
shell
.render
.text(ferese_config::tr!("No audio output available"))
.size(11)
.class(theme::Text::Color(muted)),
]
.spacing(4),
);
}
rows
}
+46 -34
View File
@@ -60,39 +60,20 @@ impl FereseShell {
} else {
theme.text_muted
});
let mut indicator = if kind == Menu::Battery {
let indicator = if kind == Menu::Notifications {
notification_indicator(
!self.notifications.entries.is_empty()
|| self
.status
.notifications
.as_ref()
.is_some_and(|n| n.count > 0),
)
} else if kind == Menu::Battery {
self.battery_indicator(theme, 1.0, battery_percentage)
} else {
accented_icon(source, metrics.icon_size, foreground, color(theme.accent)).into()
};
if kind == Menu::Notifications {
let unread = self
.status
.notifications
.as_ref()
.is_some_and(|n| n.count > 0 && !n.dnd);
let dot = container(cosmic::widget::Space::new())
.width(4)
.height(4)
.class(theme::Container::custom(move |_| container::Style {
background: unread.then_some(Background::Color(foreground)),
border: Border {
shape: BorderShape::Continuous,
radius: self.render.radius(2.0).into(),
..Default::default()
},
..Default::default()
}));
// Keep the popup anchor stable when reading clears the badge.
indicator = cosmic::iced::widget::stack![
indicator,
container(dot)
.width(metrics.icon_size)
.height(metrics.icon_size)
.align_x(Alignment::End),
]
.into();
}
let mut content = row![indicator].spacing(4).align_y(Alignment::Center);
if kind == Menu::Recording
@@ -107,7 +88,9 @@ impl FereseShell {
}
let content = bar_content(content, metrics.group_item_height);
let recording_busy = kind == Menu::Recording && self.recorder.busy();
let icon_width = if kind == Menu::Battery {
let icon_width = if kind == Menu::Notifications {
3.
} else if kind == Menu::Battery {
crate::battery_indicator::WIDTH
} else {
f32::from(metrics.icon_size)
@@ -120,7 +103,14 @@ impl FereseShell {
} else {
status_label(kind, &self.status)
})
.padding([0.0, ((metrics.height - icon_width) * 0.5).max(4.0)])
.padding([
0.0,
if kind == Menu::Notifications {
4.
} else {
((metrics.height - icon_width) * 0.5).max(4.0)
},
])
.height(metrics.group_item_height)
.on_press_with_rectangle(move |offset, bounds| {
if kind == Menu::Recording {
@@ -144,6 +134,27 @@ impl FereseShell {
}
}
fn notification_indicator<'a>(has_notifications: bool) -> Element<'a, cosmic::Action<Message>> {
container(cosmic::widget::Space::new())
.id("notification-indicator")
.width(3)
.height(13)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(if has_notifications {
cosmic::iced::Color::WHITE
} else {
cosmic::iced::Color::TRANSPARENT
})),
border: Border {
shape: BorderShape::Continuous,
radius: 1.5.into(),
..Default::default()
},
..Default::default()
}))
.into()
}
fn status_label(kind: Menu, s: &Snapshot) -> String {
match kind {
Menu::Tray => "Application tray".into(),
@@ -303,11 +314,12 @@ mod tests {
let ordinary = measure(&shell, Menu::System, false);
for (count, dnd) in [(0, false), (3, false), (0, false), (3, true)] {
shell.status.notifications = Some(status::Notifications { count, dnd });
let notification = measure(&shell, Menu::Notifications, false);
assert_eq!(
measure(&shell, Menu::Notifications, false),
ordinary,
"notification badges must stay inside the normal icon hitbox"
notification.width, 11.,
"notification mark keeps its narrow stable allocation"
);
assert_eq!(notification.height, ordinary.height);
}
for percent in [0, 9, 50, 100] {
shell.status.battery = Some(status::Battery {
+51 -16
View File
@@ -23,21 +23,38 @@ pub(super) fn view<'a>(
rows = rows.push(
shell
.render
.text(match b.status.as_str() {
.text(ferese_config::locale::text(match b.status.as_str() {
"Charging" => "Connected to power",
"Discharging" => "Running on battery power",
"Full" => "Battery fully charged",
_ => "Battery status reported by the system",
})
}))
.size(12)
.class(theme::Text::Color(muted)),
);
}
rows = rows.push(ferese_theme::menus::section_label(
"Power mode",
shell.render.font,
power_controls(shell, rows, style, true)
}
pub(super) fn power_controls<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
show_unavailable: bool,
) -> MenuRows<'a> {
let MenuStyle {
theme,
muted,
));
opacity: p,
..
} = style;
if shell.status.power_profiles.is_some() || show_unavailable {
rows = rows.push(ferese_theme::menus::section_label(
ferese_config::tr!("Power mode"),
shell.render.font,
muted,
));
}
if let Some(profiles) = &shell.status.power_profiles {
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
for (index, (label, icon, profile)) in [
@@ -69,7 +86,7 @@ pub(super) fn view<'a>(
}
}
rows = rows.push(modes);
} else {
} else if show_unavailable {
rows = rows.push(
shell
.render
@@ -114,7 +131,7 @@ pub(super) fn view<'a>(
if shell.status.reboot || shell.status.poweroff || shell.status.suspend {
rows = rows.push(ferese_theme::menus::section_label(
"Power",
ferese_config::tr!("Power"),
shell.render.font,
muted,
));
@@ -171,9 +188,12 @@ fn power_tile<'a>(
opacity,
);
let tile = container(
column![bar_icon(icon, 19, foreground), render.text(label).size(11)]
.spacing(3)
.align_x(Alignment::Center),
column![
bar_icon(icon, 19, foreground),
render.text(ferese_config::locale::text(label)).size(11)
]
.spacing(3)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.center_x(Length::Fill);
@@ -184,7 +204,13 @@ fn power_tile<'a>(
profile,
))));
motion::button(render, btn, foreground, selected, opacity)
motion::button(
render.with_radius_limit(6.),
btn,
foreground,
selected,
opacity,
)
}
fn power_tile_action<'a>(
@@ -197,9 +223,12 @@ fn power_tile_action<'a>(
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(theme.text_primary, opacity);
let tile = container(
column![bar_icon(icon, 19, foreground), render.text(label).size(11)]
.spacing(3)
.align_x(Alignment::Center),
column![
bar_icon(icon, 19, foreground),
render.text(ferese_config::locale::text(label)).size(11)
]
.spacing(3)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.center_x(Length::Fill);
@@ -208,5 +237,11 @@ fn power_tile_action<'a>(
.padding([8, 2])
.on_press(cosmic::Action::App(Message::ConfirmPower(action)));
motion::button(render, btn, foreground, false, opacity)
motion::button(
render.with_radius_limit(6.),
btn,
foreground,
false,
opacity,
)
}
+5 -1
View File
@@ -9,6 +9,7 @@ pub(super) fn view<'a>(
offset: i32,
theme: ShellTheme,
opacity: f32,
settings: &ferese_config::calendar::Settings,
) -> Element<'a, cosmic::Action<Message>> {
let primary = color_with_opacity(theme.text_primary, opacity);
ferese_theme::calendar::grid(
@@ -40,6 +41,7 @@ pub(super) fn view<'a>(
opacity,
),
],
settings,
)
}
@@ -48,6 +50,7 @@ pub(super) fn hub<'a>(
offset: i32,
theme: ShellTheme,
opacity: f32,
settings: &ferese_config::calendar::Settings,
) -> Element<'a, cosmic::Action<Message>> {
let primary = color_with_opacity(theme.text_primary, opacity);
let navigation = |label: &'static str, message: Message| {
@@ -76,8 +79,9 @@ pub(super) fn hub<'a>(
opacity,
[
navigation("‹", Message::CalendarMonth(-1)),
navigation("Today", Message::CalendarToday),
navigation(ferese_config::tr!("Today"), Message::CalendarToday),
navigation("›", Message::CalendarMonth(1)),
],
settings,
)
}
+10 -2
View File
@@ -151,7 +151,15 @@ pub(super) fn shell_switch<'a>(
render,
button::custom(toggle)
.padding(4)
.name(format!("{label}: {}", if enabled { "on" } else { "off" }))
.name(format!(
"{}: {}",
ferese_config::locale::translate(label),
if enabled {
ferese_config::tr!("On")
} else {
ferese_config::tr!("Off")
}
))
.on_press(cosmic::Action::App(Message::Control(action))),
color(palette.text_primary),
false,
@@ -168,7 +176,7 @@ pub(super) fn toggle_row<'a>(
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
ferese_theme::menus::row(
label,
ferese_config::locale::translate(label),
shell_switch(render, label, on, action, palette, opacity),
render.font,
)
+10 -13
View File
@@ -286,16 +286,16 @@ mod tests {
#[test]
fn clicking_the_same_control_at_new_bounds_repositions_instead_of_toggling() {
let mut shell = crate::tests::shell_with_measured_panel();
shell.status.network = Some(crate::status::Network {
enabled: true,
connection: Some("Test network".into()),
signal: 90,
shell.status.audio = Some(crate::status::Audio {
volume: 50,
muted: false,
output: String::new(),
});
let bar = shell.outputs[0].bar;
let mut anchor = PopoverAnchor {
parent: bar,
panel: shell.config.panels[0].id.clone(),
item: Some(crate::panel::ItemId("network".into())),
item: Some(crate::panel::ItemId("audio".into())),
rectangle: Rectangle {
x: 1000,
y: 0,
@@ -303,15 +303,15 @@ mod tests {
height: 36,
},
};
drop(shell.open_menu(Menu::Network, anchor.clone()));
drop(shell.open_menu(Menu::Audio, anchor.clone()));
let popup = shell.menu.as_ref().unwrap().id;
anchor.rectangle.x = 800;
drop(shell.open_menu(Menu::Network, anchor.clone()));
drop(shell.open_menu(Menu::Audio, anchor.clone()));
let menu = shell.menu.as_ref().unwrap();
assert_eq!(menu.id, popup);
assert_eq!(menu.anchor, anchor);
assert!(!menu.motion.closing());
drop(shell.open_menu(Menu::Network, anchor));
drop(shell.open_menu(Menu::Audio, anchor));
assert!(shell.menu.is_none());
}
@@ -443,7 +443,7 @@ mod tests {
let anchor = PopoverAnchor {
parent: window::Id::unique(),
panel: panel.id.clone(),
item: Some(crate::panel::ItemId("network".into())),
item: Some(crate::panel::ItemId("audio".into())),
rectangle: Rectangle {
x: 0,
y: 0,
@@ -453,10 +453,7 @@ mod tests {
};
assert!(anchor.belongs_to(&panel));
let items = &mut panel.end.groups[0].items;
let network = items
.iter()
.position(|item| item.id.0 == "network")
.unwrap();
let network = items.iter().position(|item| item.id.0 == "audio").unwrap();
items[network].visible = false;
assert!(!anchor.belongs_to(&panel));
panel.end.groups[0].items.remove(network);
+31 -16
View File
@@ -58,7 +58,7 @@ pub enum Menu {
impl Menu {
fn action_error(self, error: &status::ActionError) -> Option<&str> {
let relevant = self == Self::System
let relevant = matches!(self, Self::System | Self::Calendar)
|| matches!(
(self, &error.action),
(
@@ -78,16 +78,15 @@ impl Menu {
pub(crate) fn width(self) -> f32 {
match self {
Self::System => 360.0,
Self::System | Self::Calendar => 360.0,
Self::Battery => 328.0,
Self::Calendar => 268.0,
Self::Notifications | Self::Media => 368.0,
_ => 300.0,
}
}
fn height_limit(self) -> f32 {
if self == Self::Calendar { 270.0 } else { 720.0 }
720.0
}
fn title(self) -> &'static str {
@@ -236,12 +235,18 @@ impl FereseShell {
Menu::Bluetooth => bluetooth::view(self, rows, style, true),
Menu::Audio => audio::view(self, rows, style, false),
Menu::Battery => battery::view(self, rows, style),
Menu::Calendar => rows.push(calendar::hub(
self.render,
self.calendar_offset,
theme,
opacity,
)),
Menu::Calendar => system::view(
self,
rows.push(calendar::hub(
self.render,
self.calendar_offset,
theme,
opacity,
&self.config.status.calendar,
)),
style,
true,
),
Menu::Media => media::hub(self, rows, style),
Menu::Notifications => {
let mut rows = notification_controls(self, rows, style, true);
@@ -471,9 +476,18 @@ impl FereseShell {
Menu::Bluetooth => bluetooth::view(self, rows, style, false),
Menu::Audio => audio::view(self, rows, style, false),
Menu::Battery => battery::view(self, rows, style),
Menu::Calendar => {
rows.push(calendar::view(self.render, self.calendar_offset, theme, p))
}
Menu::Calendar => system::view(
self,
rows.push(calendar::view(
self.render,
self.calendar_offset,
theme,
p,
&self.config.status.calendar,
)),
style,
true,
),
Menu::Notifications => notification_controls(self, rows, style, false),
Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)),
Menu::CaptureStatus => rows.push(self.view_hub_menu(kind, p)),
@@ -844,7 +858,8 @@ mod tests {
] {
assert_eq!(
menu.action_error(&error),
(menu == relevant || menu == Menu::System).then_some("control failed"),
(menu == relevant || matches!(menu, Menu::System | Menu::Calendar))
.then_some("control failed"),
"{menu:?} showed the wrong control error: {:?}",
error.action
);
@@ -858,8 +873,8 @@ mod tests {
assert_eq!(Menu::Network.width(), 300.0);
assert_eq!(Menu::Bluetooth.width(), 300.0);
assert_eq!(Menu::Battery.width(), 328.0);
assert_eq!(Menu::Calendar.width(), 268.0);
assert_eq!(Menu::Calendar.height_limit(), 270.0);
assert_eq!(Menu::Calendar.width(), 360.0);
assert_eq!(Menu::Calendar.height_limit(), 720.0);
assert_eq!(Menu::Audio.width(), 300.0);
assert_eq!(Menu::Notifications.width(), 368.0);
}
+148 -16
View File
@@ -4,7 +4,7 @@ use cosmic::{Element, theme};
use super::bar::status_icon;
use super::controls::control_card;
use super::{Menu, MenuRows, MenuStyle, audio, battery, notification_controls};
use super::{Menu, MenuRows, MenuStyle, audio, battery};
use crate::status::{self, Action};
use crate::{FereseShell, Message, accented_icon, color, color_with_opacity, motion};
@@ -28,11 +28,17 @@ pub(super) fn view<'a>(
(Mode::Auto, "◐ Auto"),
] {
modes = modes.push(motion::button(
shell.render,
button::custom(shell.render.text(label).font(shell.render.font).size(13))
.width(Length::Fill)
.padding(9)
.on_press(cosmic::Action::App(Message::ThemeMode(mode))),
shell.render.with_radius_limit(6.),
button::custom(
shell
.render
.text(ferese_config::locale::text(label))
.font(shell.render.font)
.size(13),
)
.width(Length::Fill)
.padding(9)
.on_press(cosmic::Action::App(Message::ThemeMode(mode))),
primary,
shell.config.theme_mode == mode,
p,
@@ -80,9 +86,9 @@ pub(super) fn view<'a>(
shell
.render
.text(if theme.appearance == Appearance::Light {
"Auto · currently light"
ferese_config::tr!("Auto · currently light")
} else {
"Auto · currently dark"
ferese_config::tr!("Auto · currently dark")
})
.font(shell.render.font)
.size(12)
@@ -96,9 +102,11 @@ pub(super) fn view<'a>(
shell.render,
"Wi-Fi",
if n.enabled {
n.connection.as_deref().unwrap_or("Not connected")
n.connection
.as_deref()
.unwrap_or(ferese_config::tr!("Not connected"))
} else {
"Off"
ferese_config::tr!("Off")
},
status_icon(Menu::Network, &shell.status).0,
n.enabled,
@@ -112,9 +120,12 @@ pub(super) fn view<'a>(
shell.render,
"Bluetooth",
if b.enabled {
b.devices.first().map(String::as_str).unwrap_or("On")
b.devices
.first()
.map(String::as_str)
.unwrap_or(ferese_config::tr!("On"))
} else {
"Off"
ferese_config::tr!("Off")
},
status_icon(Menu::Bluetooth, &shell.status).0,
b.enabled,
@@ -155,8 +166,7 @@ pub(super) fn view<'a>(
}
rows = audio::view(shell, rows, style, true);
rows = battery::brightness(shell, rows, style);
rows = super::night_light::view(shell, rows, style);
rows = notification_controls(shell, rows, style, true);
rows = battery::power_controls(shell, rows, style, false);
rows
}
@@ -246,12 +256,18 @@ fn connection_control<'a>(
.class(ferese_theme::controls::filled_button(
fill,
foreground,
render.radius(14.),
render.radius(6.),
opacity,
))
.into()
} else {
motion::button(render, button, foreground, false, opacity)
motion::button(
render.with_radius_limit(6.),
button,
foreground,
false,
opacity,
)
};
container(tile).width(Length::FillPortion(1)).into()
}
@@ -296,6 +312,122 @@ pub(super) fn heading<'a>(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn combined_controls_include_audio_and_confirmed_power_without_dnd_or_night_light() {
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, widget};
use cosmic::iced::{Event, Rectangle, Size, mouse};
#[derive(Default)]
struct Labels(Vec<(String, Rectangle)>);
impl widget::Operation for Labels {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
self.0.push((text.into(), bounds));
}
}
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = crate::tests::shell_with_measured_panel();
app.status.audio = Some(status::Audio {
volume: 45,
muted: false,
output: "Test output".into(),
});
app.status.power_profiles = Some(status::PowerProfiles {
active: "balanced".into(),
available: [true; 3],
});
app.status.notifications = Some(status::Notifications {
count: 1,
dnd: false,
});
app.status.poweroff = true;
app.status.reboot = true;
app.status.suspend = true;
let theme = app.config.theme;
let style = MenuStyle {
theme,
primary: color(theme.text_primary),
muted: color(theme.text_muted),
opacity: 1.,
};
let mut view: Element<'_, cosmic::Action<Message>> =
view(&app, column([]), style, true).into();
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(328., 1000.)),
);
let mut labels = Labels::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
for key in [
"Volume",
"Power mode",
"Balanced",
"Power off",
"Restart",
"Suspend",
] {
assert!(
labels
.0
.iter()
.any(|(text, _)| text == ferese_config::locale::text(key)),
"missing {key}"
);
}
for key in ["Do Not Disturb", "Night light"] {
assert!(
!labels
.0
.iter()
.any(|(text, _)| text == ferese_config::locale::text(key))
);
}
let point = labels
.0
.iter()
.find(|(text, _)| text == ferese_config::tr!("Power off"))
.unwrap()
.1
.center();
let mut messages = Vec::new();
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
view.as_widget_mut().update(
&mut tree,
&Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(point),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&node.bounds(),
);
}
assert!(messages.iter().any(|message| matches!(
message,
cosmic::Action::App(Message::ConfirmPower(Action::Poweroff))
)));
assert!(
!messages
.iter()
.any(|message| matches!(message, cosmic::Action::App(Message::ExecutePower)))
);
}
#[test]
fn connection_captions_fit_one_short_line() {
assert_eq!(super::connection_caption("88%"), "88%");
+10 -5
View File
@@ -556,9 +556,11 @@ impl FereseShell {
let rows = match &modal.content {
Content::Power(action) => {
let mut rows = column![
self.render.text(action.title()).size(17),
self.render
.text(action.description())
.text(ferese_config::locale::text(action.title()))
.size(17),
self.render
.text(ferese_config::locale::text(action.description()))
.size(13)
.class(theme::Text::Color(palette.muted)),
]
@@ -588,9 +590,12 @@ impl FereseShell {
.as_ref()
.is_some_and(|approval| approval.force())
{
format!("{} anyway", action.label())
ferese_config::locale::format(
"{action} anyway",
&[("action", ferese_config::locale::translate(action.label()))],
)
} else {
action.label().to_owned()
ferese_config::locale::translate(action.label())
};
if let Some(error) = &modal.error {
rows = rows.push(self.render.text(error).size(13));
@@ -600,7 +605,7 @@ impl FereseShell {
row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button(
"Cancel",
ferese_config::tr!("Cancel"),
self.render.font,
palette,
false
+225
View File
@@ -0,0 +1,225 @@
use cosmic::Element;
use cosmic::iced::{Length, window};
use cosmic::widget::{button, container, icon, mouse_area, row};
use crate::control::{ShellSnapshot, WindowSnapshot};
use crate::{FereseShell, Message, color, motion};
// Launch order stays stable as focus changes. Each open window gets its own
// entry, including multiple windows belonging to the same application.
fn windows(snapshot: &ShellSnapshot) -> Vec<&WindowSnapshot> {
let mut windows: Vec<_> = snapshot.windows.iter().collect();
windows.sort_by_key(|window| window.id);
windows
}
pub(super) fn caption(window: &WindowSnapshot) -> &str {
if !window.title.trim().is_empty() {
&window.title
} else if !window.app_id.is_empty() {
&window.app_id
} else {
ferese_config::tr!("Window")
}
}
impl FereseShell {
pub(super) fn view_window_tray(
&self,
bar: window::Id,
instance: &str,
allocated: Option<f32>,
height: f32,
) -> Element<'_, cosmic::Action<Message>> {
let windows = windows(&self.snapshot);
if windows.is_empty() {
return container(row([])).width(0).into();
}
let palette = self.config.theme.for_bar();
let foreground = color(palette.text_primary);
let mut entries = row([]).spacing(2);
let natural_width = windows.len() as f32 * 32. - 2.;
for window in windows {
let foreground = if window.urgent {
color(palette.accent)
} else {
foreground
};
let entry = button::custom(
container(icon(self.workspace_ui.app_icon(&window.app_id)).size(18))
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.name(caption(window))
.width(30)
.height(height)
.padding(4)
.on_press(cosmic::Action::App(Message::ActivateWindow(window.id)));
let entry = motion::button(
self.render.with_radius_limit(5.),
entry,
foreground,
window.focused,
1.,
);
let target = cosmic::iced::advanced::widget::Id::new(format!(
"window-tray:{bar:?}:{instance}:{}",
window.id
));
let entry = mouse_area(container(entry).id(target.clone()))
.on_enter(cosmic::Action::App(Message::HoverWindow(
bar,
window.id,
target.clone(),
true,
)))
.on_exit(cosmic::Action::App(Message::HoverWindow(
bar,
window.id,
target.clone(),
false,
)));
let entry = if self.workspace_ui.hovering(bar, &target) {
entry
} else {
entry.on_move(move |_| {
cosmic::Action::App(Message::HoverWindow(bar, window.id, target.clone(), true))
})
};
entries = entries.push(entry);
}
cosmic::iced::widget::scrollable(entries)
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
cosmic::iced::widget::scrollable::Scrollbar::default()
.width(0)
.scroller_width(0),
))
.width(allocated.unwrap_or(natural_width.min(360.)).max(0.))
.height(height)
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn window(id: u64, title: &str) -> WindowSnapshot {
WindowSnapshot {
id,
title: title.into(),
app_id: "test.app".into(),
workspace: 1,
geometry: None,
focused: false,
urgent: false,
fullscreen: false,
maximized: false,
floating: false,
}
}
#[test]
fn each_icon_activates_its_own_window_at_the_drawn_bounds() {
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, widget};
use cosmic::iced::{Event, Rectangle, Size, mouse};
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = crate::tests::shell_with_measured_panel();
app.snapshot.windows = vec![window(1, "One"), window(2, "Two"), window(3, "Three")];
let bar = app.outputs[0].bar;
struct Bounds(Vec<(widget::Id, Option<Rectangle>)>);
impl widget::Operation for Bounds {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if let Some((_, found)) = self.0.iter_mut().find(|(target, _)| Some(&*target) == id)
{
*found = Some(bounds);
}
}
}
let mut bounds = Bounds(
(1..=3)
.map(|id| {
(
widget::Id::new(format!("window-tray:{bar:?}:test:{id}")),
None,
)
})
.collect(),
);
let mut view = app.view_window_tray(bar, "test", None, 28.);
let mut tree = widget::Tree::new(&view);
let node = view
.as_widget_mut()
.layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(360., 36.)),
)
.move_to((300., 0.));
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut bounds);
for (index, (_, bounds)) in bounds.0.iter().enumerate() {
let point = bounds.expect("icon bounds").center();
let mut messages = Vec::new();
for event in [
mouse::Event::CursorMoved { position: point },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
view.as_widget_mut().update(
&mut tree,
&Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(point),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&node.bounds(),
);
}
let activated: Vec<_> = messages
.iter()
.filter_map(|m| match m {
cosmic::Action::App(Message::ActivateWindow(id)) => Some(*id),
_ => None,
})
.collect();
assert_eq!(activated, [index as u64 + 1], "icon at {point:?}");
}
}
#[test]
fn focus_changes_preserve_order_and_closed_windows_disappear() {
let mut snapshot = ShellSnapshot::default();
snapshot.windows = vec![window(3, "Third"), window(1, "First"), window(2, "Second")];
snapshot.windows[0].focused = true;
assert_eq!(
windows(&snapshot).iter().map(|w| w.id).collect::<Vec<_>>(),
[1, 2, 3]
);
snapshot.windows[0].focused = false;
snapshot.windows[1].focused = true;
snapshot.windows.retain(|w| w.id != 2);
assert_eq!(
windows(&snapshot).iter().map(|w| w.id).collect::<Vec<_>>(),
[1, 3]
);
assert_eq!(caption(windows(&snapshot)[0]), "First");
snapshot.windows.clear();
assert!(windows(&snapshot).is_empty());
}
#[test]
fn empty_titles_fall_back_to_app_identity() {
assert_eq!(caption(&window(1, " ")), "test.app");
}
}
+110 -53
View File
@@ -13,23 +13,22 @@ use crate::{FereseShell, ShellSnapshot, motion};
impl FereseShell {
pub(super) fn update_workspace_ui(&mut self) -> Task<Message> {
let needs_icons = [
&self.config.panels[0].start,
&self.config.panels[0].center,
&self.config.panels[0].end,
]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.any(|item| {
item.visible
&& matches!(
item.kind,
crate::panel::ItemKind::Workspaces {
style: ferese_panel::WorkspaceStyle::AppIcons
}
)
});
let needs_icons = self
.config
.panels
.iter()
.flat_map(|panel| [&panel.start, &panel.center, &panel.end])
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.any(|item| {
item.visible
&& matches!(
item.kind,
crate::panel::ItemKind::Workspaces {
style: ferese_panel::WorkspaceStyle::AppIcons
} | crate::panel::ItemKind::WindowTray
)
});
self.workspace_ui
.update(&self.snapshot, self.config.animations);
let icons = self.workspace_ui.load_icons(&self.snapshot, needs_icons);
@@ -39,6 +38,9 @@ impl FereseShell {
.workspaces
.iter()
.any(|workspace| workspace.id == hover.workspace)
|| hover
.window
.is_some_and(|id| !self.snapshot.windows.iter().any(|window| window.id == id))
}) {
self.dismiss_workspace_tooltip()
} else {
@@ -88,20 +90,19 @@ fn discover_icons(entries: Option<Arc<Vec<fde::DesktopEntry>>>, app_ids: Vec<Str
.into_iter()
.map(|app_id| {
let entry = fde::find_app_by_id(&entries, fde::unicase::Ascii::new(app_id.as_str()));
let source = entry
.and_then(fde::DesktopEntry::icon)
.unwrap_or("application-x-executable");
let handle = if source.starts_with('/') {
icon::from_path(source.into())
} else {
icon::from_name(source)
.prefer_svg(true)
.size(32)
.fallback(Some(icon::IconFallback::Names(vec![
"application-x-executable".into(),
])))
.handle()
};
let path = entry.and_then(fde::DesktopEntry::icon).and_then(|source| {
if source.starts_with('/') {
Some(std::path::PathBuf::from(source))
} else {
icon::from_name(source).prefer_svg(true).size(32).path()
}
});
// Named::handle returns an empty SVG when neither an application
// icon nor a theme fallback exists. Keep every live window visible.
let handle = path
.filter(|path| path.is_file())
.map(icon::from_path)
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION));
(app_id, handle)
})
.collect();
@@ -123,6 +124,7 @@ pub(super) struct WorkspaceUi {
struct Hover {
bar: window::Id,
workspace: u64,
window: Option<u64>,
target: Id,
serial: u64,
}
@@ -218,6 +220,7 @@ impl FereseShell {
self.workspace_ui.hovered = Some(Hover {
bar,
workspace,
window: None,
target,
serial,
});
@@ -227,6 +230,23 @@ impl FereseShell {
))
}
pub(super) fn hover_window(
&mut self,
bar: window::Id,
id: u64,
target: Id,
entered: bool,
) -> Task<Message> {
let Some(window) = self.snapshot.windows.iter().find(|window| window.id == id) else {
return self.dismiss_workspace_tooltip();
};
let task = self.hover_workspace(bar, window.workspace, target, entered);
if entered && let Some(hover) = &mut self.workspace_ui.hovered {
hover.window = Some(id);
}
task
}
pub(super) fn locate_workspace_tooltip(&self, serial: u64) -> Task<Message> {
let Some(hover) = self
.workspace_ui
@@ -274,12 +294,16 @@ impl FereseShell {
{
return self.dismiss_workspace_tooltip();
}
if !self
.snapshot
.workspaces
.iter()
.any(|workspace| workspace.id == hover.workspace)
{
if hover.window.map_or_else(
|| {
!self
.snapshot
.workspaces
.iter()
.any(|workspace| workspace.id == hover.workspace)
},
|id| !self.snapshot.windows.iter().any(|window| window.id == id),
) {
return self.dismiss_workspace_tooltip();
}
let id = window::Id::unique();
@@ -323,26 +347,36 @@ impl FereseShell {
}
fn view_workspace_tooltip(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(workspace) = self.workspace_ui.hovered.as_ref().and_then(|hover| {
self.snapshot
let Some(hover) = self.workspace_ui.hovered.as_ref() else {
return self.render.text(ferese_config::tr!("")).into();
};
let label = if let Some(id) = hover.window {
let Some(window) = self.snapshot.windows.iter().find(|window| window.id == id) else {
return self.render.text(ferese_config::tr!("")).into();
};
crate::window_tray::caption(window).to_owned()
} else {
let Some(workspace) = self
.snapshot
.workspaces
.iter()
.find(|workspace| workspace.id == hover.workspace)
}) else {
return self.render.text(ferese_config::tr!("")).into();
else {
return self.render.text(ferese_config::tr!("")).into();
};
let active = self
.snapshot
.outputs
.iter()
.any(|output| output.active_workspace == workspace.id);
ferese_theme::workspaces::tooltip_label(&ferese_theme::workspaces::Workspace {
name: (&workspace.name).into(),
index: workspace.index,
window_count: workspace.window_count,
active,
visible_elsewhere: false,
})
};
let active = self
.snapshot
.outputs
.iter()
.any(|output| output.active_workspace == workspace.id);
let label = ferese_theme::workspaces::tooltip_label(&ferese_theme::workspaces::Workspace {
name: (&workspace.name).into(),
index: workspace.index,
window_count: workspace.window_count,
active,
visible_elsewhere: false,
});
cosmic::widget::autosize::autosize(
container(
self.render
@@ -503,6 +537,13 @@ impl WorkspaceUi {
})
.collect()
}
pub(super) fn app_icon(&self, app_id: &str) -> icon::Handle {
self.icons
.get(app_id)
.cloned()
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION))
}
}
#[cfg(test)]
@@ -550,6 +591,22 @@ mod tests {
});
}
#[test]
fn window_tooltips_are_cancelled_when_the_window_closes() {
let mut app = crate::tests::shell_with_measured_panel();
app.snapshot = snapshot(1);
add_window(&mut app.snapshot, "test.app");
let bar = app.outputs[0].bar;
let target = Id::new("window-tray:test:0");
drop(app.hover_window(bar, 0, target, true));
assert_eq!(app.workspace_ui.hovered.as_ref().unwrap().window, Some(0));
let serial = app.workspace_ui.serial;
app.snapshot.windows.clear();
drop(app.update_workspace_ui());
assert!(app.workspace_ui.hovered.is_none());
assert_eq!(app.locate_workspace_tooltip(serial).units(), 0);
}
#[test]
fn discovery_is_deferred_and_only_one_request_runs_at_a_time() {
let mut ui = WorkspaceUi::default();