feat(settings): add grouped configuration hub, languages and tray controls

This commit is contained in:
aria committed 2026-10-28 03:37:22 +09:00
1 parent 6f34fd080f
commit cc3b6f903f
90 files changed
+3625 -408

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ tempfile.workspace = true
tokio = { version = "1", features = ["time", "rt", "sync", "net", "macros"] }
wayland-client.workspace = true
zbus = "5.19.0"
system-tray = { version = "0.8.9", default-features = false, features = ["data"] }
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
url = "2.5.8"
+14 -4
View File
@@ -583,7 +583,12 @@ impl FereseShell {
) -> (Element<'_, cosmic::Action<Message>>, bool) {
use ferese_panel::Representation;
let Some(presentation) = self.activities.flat.get(&id) else {
return (container(self.render.text("")).width(0).into(), false);
return (
container(self.render.text(ferese_config::tr!("")))
.width(0)
.into(),
false,
);
};
let Some(activity) = presentation.selection.visible.first().and_then(|id| {
self.activities
@@ -593,7 +598,12 @@ impl FereseShell {
.chain(self.activities.native.iter())
.find(|entry| &entry.id == id)
}) else {
return (container(self.render.text("")).width(0).into(), false);
return (
container(self.render.text(ferese_config::tr!("")))
.width(0)
.into(),
false,
);
};
let palette = self.config.theme.palette();
let result_age = activity.result_deadline.map(|deadline| {
@@ -1089,7 +1099,7 @@ impl FereseShell {
rows.push(
container(
self.render
.text("No active tasks")
.text(ferese_config::tr!("No active tasks"))
.size(12)
.class(theme::Text::Color(palette.muted.scale_alpha(opacity))),
)
@@ -1167,7 +1177,7 @@ impl FereseShell {
{
body = body.push(
button::custom(icons::tinted(icons::MEDIA_PLAY, 16, palette.text))
.name("Play or pause")
.name(ferese_config::tr!("Play or pause"))
.on_press(cosmic::Action::App(Message::MediaAction(
self.media.action("play-pause"),
))),
@@ -122,7 +122,7 @@ impl FereseShell {
badge,
column![
self.render
.text("Pomodoro")
.text(ferese_config::tr!("Pomodoro"))
.size(13)
.class(theme::Text::Color(primary)),
self.render
+13 -6
View File
@@ -75,7 +75,7 @@ impl FereseShell {
pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
if self.bar_hidden(id) {
return container(self.render.text(""))
return container(self.render.text(ferese_config::tr!("")))
.width(Length::Fill)
.height(Length::Fill)
.into();
@@ -94,6 +94,7 @@ impl FereseShell {
let effects = output.and_then(|output| output.effects.as_ref());
let compositor_material = effects.is_some();
let availability = Availability {
tray: self.tray.available(&self.config.status.tray),
capture: self.capture_status.active(),
activities: self.activity_available(id),
media: self.media.snapshot.selected.is_some(),
@@ -324,9 +325,12 @@ impl FereseShell {
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
));
let badge = container(
row![title, self.render.text("Esc to exit").size(12)]
.spacing(12)
.align_y(cosmic::iced::Alignment::Center),
row![
title,
self.render.text(ferese_config::tr!("Esc to exit")).size(12)
]
.spacing(12)
.align_y(cosmic::iced::Alignment::Center),
)
.id(badge_id.clone())
.padding([0, 12])
@@ -480,6 +484,9 @@ impl FereseShell {
control_height,
);
}
ItemKind::Tray => {
return (self.view_tray(id), false);
}
ItemKind::Media => self.view_media_item(representation, selected, width),
ItemKind::QuickSettings => {
self.view_status_item(status_ui::Menu::System, false, selected)
@@ -535,7 +542,7 @@ impl FereseShell {
button::custom(bar_content(content, bar.group_item_height))
.padding([0, 4])
.height(bar.group_item_height)
.name("Open calendar")
.name(ferese_config::tr!("Open calendar"))
.on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::OpenMenu(
status_ui::Menu::Calendar,
@@ -558,7 +565,7 @@ impl FereseShell {
bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground),
control_height,
))
.name("Display mode")
.name(ferese_config::tr!("Display mode"))
.height(control_height)
.padding([
0.0,
+10 -17
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug)]
pub(crate) struct ShellConfig {
pub(crate) ui: ferese_config::locale::Settings,
pub(crate) terminal: Vec<String>,
pub(crate) activities: ferese_config::activities::Settings,
pub(crate) notifications: ferese_config::notifications::NotificationConfig,
@@ -24,6 +25,7 @@ impl Default for ShellConfig {
fn default() -> Self {
let status = StatusConfig::default();
Self {
ui: Default::default(),
terminal: vec!["foot".into()],
activities: Default::default(),
notifications: Default::default(),
@@ -38,23 +40,7 @@ impl Default for ShellConfig {
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct StatusConfig {
pub(crate) keybinding_guide: bool,
pub(crate) low_battery_threshold: u8,
pub(crate) settings_command: Option<Vec<String>>,
}
impl Default for StatusConfig {
fn default() -> Self {
Self {
keybinding_guide: true,
low_battery_threshold: 20,
settings_command: Some(vec!["ferese-settings".into()]),
}
}
}
pub(crate) use ferese_config::status::Settings as StatusConfig;
#[derive(Clone, Copy, Debug)]
pub(crate) struct ShellTheme {
@@ -190,6 +176,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)]
struct FereseConfig {
#[serde(default)]
ui: ferese_config::locale::Settings,
#[serde(default)]
commands: std::collections::HashMap<String, Vec<String>>,
#[serde(default)]
@@ -405,6 +393,10 @@ fn parse_document(
.map_err(|error| ferese_config::Error::from(error.to_string()))
{
Ok(config) => {
config
.status
.validate()
.map_err(ferese_config::Error::from)?;
if let Some(panels) = &config.panels {
ferese_panel::validate(panels).map_err(ferese_config::Error::from)?;
}
@@ -424,6 +416,7 @@ fn parse_document(
theme.bar_radius = theme.material_radius;
Ok(ShellConfig {
ui: config.ui,
terminal: config
.commands
.get("terminal")
+3 -1
View File
@@ -415,7 +415,9 @@ impl FereseShell {
.is_some_and(|modal| modal.focus_visible);
let pending = model.pending();
let mut header = row![
self.render.text("Display mode").size(17),
self.render
.text(ferese_config::tr!("Display mode"))
.size(17),
cosmic::iced::widget::Space::new().width(Length::Fill)
]
.align_y(cosmic::iced::Alignment::Center)
+2 -2
View File
@@ -141,7 +141,7 @@ impl FereseShell {
suggestion = suggestion.push(
container(
self.render
.text("Tab")
.text(ferese_config::tr!("Tab"))
.size(12)
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(14.into()))
.class(theme::Text::Color(muted)),
@@ -440,7 +440,7 @@ impl FereseShell {
if launcher.rows.is_empty() {
cards = cards.push(
self.render
.text("No open windows")
.text(ferese_config::tr!("No open windows"))
.size(14)
.class(theme::Text::Color(muted)),
);
+16 -2
View File
@@ -2,6 +2,7 @@ mod activities;
mod bar;
mod battery_indicator;
mod surfaces;
mod tray;
use bar::{bar_content, output_bar_hidden};
use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface};
@@ -151,6 +152,7 @@ type WallpaperLoad =
cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
struct FereseShell {
tray: tray::Model,
core: Core,
config: ShellConfig,
theme_snapshot: ferese_ipc::theme::Snapshot,
@@ -205,6 +207,8 @@ struct OutputSurfaces {
#[derive(Clone, Debug)]
enum Message {
Tray(tray::Event),
TrayInput(tray::Input),
Launcher(launcher::Message),
LauncherEnter(window::Id, bool),
NightLightTemperature(u16),
@@ -357,6 +361,7 @@ impl cosmic::Application for FereseShell {
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
recorder: recording::Recorder::default(),
tray: tray::Model::default(),
capture_status: capture_status::Model::new(),
media: media::Model::default(),
activities: activities::Model::default(),
@@ -453,6 +458,7 @@ impl cosmic::Application for FereseShell {
activities::subscription::subscription().map(Message::ActivitiesUpdated),
self.activity_tick_subscription()
.map(|_| Message::ActivityTick),
tray::subscription().map(Message::Tray),
self.media.subscription().map(Message::MediaUpdated),
self.media_tick_subscription().map(|_| Message::MediaTick),
self.media.art.subscription().map(Message::MediaArt),
@@ -478,6 +484,11 @@ impl cosmic::Application for FereseShell {
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
let task = match message {
Message::Tray(event) => {
self.tray.update(event);
Task::none()
}
Message::TrayInput(input) => self.tray_input(input),
Message::Launcher(message) => self.update_launcher(message),
Message::LauncherEnter(id, alt) => {
if self.launcher.as_ref().is_some_and(|l| l.id == id) {
@@ -1150,11 +1161,11 @@ impl cosmic::Application for FereseShell {
}
fn view(&self) -> Element<'_, Self::Message> {
self.render.text("").into()
self.render.text(ferese_config::tr!("")).into()
}
fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> {
self.render.text("").into()
self.render.text(ferese_config::tr!("")).into()
}
}
@@ -1185,6 +1196,8 @@ impl FereseShell {
}
fn apply_config(&mut self, mut config: config::ShellConfig) -> Task<Message> {
ferese_config::locale::set(config.ui.language);
self.clock = clock::current_time();
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
config.wallpaper.path = None;
}
@@ -1386,6 +1399,7 @@ mod tests {
status_service: status::Service::start(None),
status: Default::default(),
recorder: Default::default(),
tray: tray::Model::default(),
capture_status: Default::default(),
media: Default::default(),
activities: Default::default(),
+1 -1
View File
@@ -699,7 +699,7 @@ mod tests {
))
.push(
cosmic::widget::button::custom(cosmic::widget::text("Action"))
.name("Accessible action")
.name(ferese_config::tr!("Accessible action"))
.on_press(())
.width(100)
.height(40),
+5 -5
View File
@@ -723,7 +723,7 @@ impl FereseShell {
.spacing(3)
.push(
self.render
.text("Notifications")
.text(ferese_config::tr!("Notifications"))
.size(16)
.font(bold)
.class(theme::Text::Color(primary)),
@@ -767,7 +767,7 @@ impl FereseShell {
.spacing(2)
.push(
self.render
.text("Do Not Disturb")
.text(ferese_config::tr!("Do Not Disturb"))
.size(12)
.font(bold)
.class(theme::Text::Color(primary)),
@@ -821,14 +821,14 @@ impl FereseShell {
))
.push(
self.render
.text("You're all caught up")
.text(ferese_config::tr!("You're all caught up"))
.size(15)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(
self.render
.text("New notifications will appear here.")
.text(ferese_config::tr!("New notifications will appear here."))
.size(11)
.class(theme::Text::Color(muted)),
);
@@ -843,7 +843,7 @@ impl FereseShell {
.align_y(alignment::Vertical::Center)
.push(
self.render
.text("Recent")
.text(ferese_config::tr!("Recent"))
.size(11)
.font(bold)
.class(theme::Text::Color(muted))
+1 -1
View File
@@ -1101,7 +1101,7 @@ impl FereseShell {
button::custom(
icons::tinted(icons::morph::CLOSE, 18, icon_primary).opacity(opacity),
)
.name("Collapse panel hub")
.name(ferese_config::tr!("Collapse panel hub"))
.padding([5, 3])
.width(30)
.height(28)
+2 -2
View File
@@ -149,7 +149,7 @@ impl FereseShell {
return row![
self.battery_indicator(self.config.theme, opacity, true),
self.render
.text("Low battery")
.text(ferese_config::tr!("Low battery"))
.size(13)
.class(theme::Text::Color(foreground))
.width(width)
@@ -202,7 +202,7 @@ impl FereseShell {
if expanded {
strip = strip.push(
self.render
.text("Recording")
.text(ferese_config::tr!("Recording"))
.size(13)
.class(theme::Text::Color(foreground))
.width(Length::Fill),
+1
View File
@@ -9,6 +9,7 @@ pub(crate) struct Rendering {
impl Rendering {
pub fn new(config: &crate::config::ShellConfig) -> Self {
ferese_config::locale::set(config.ui.language);
Self {
font: crate::configured_font(config.font_family.as_deref()),
motion: config.animations,
+8 -2
View File
@@ -21,8 +21,14 @@ pub(super) fn view<'a>(
opacity: p,
} = style;
if let Some(a) = &shell.status.audio {
let mut volume_heading = row![shell.render.text("Volume").size(13).width(Length::Fill)]
.align_y(Alignment::Center);
let mut volume_heading = row![
shell
.render
.text(ferese_config::tr!("Volume"))
.size(13)
.width(Length::Fill)
]
.align_y(Alignment::Center);
if combined {
volume_heading = volume_heading.push(shell_switch(
shell.render,
+2
View File
@@ -146,6 +146,7 @@ impl FereseShell {
fn status_label(kind: Menu, s: &Snapshot) -> String {
match kind {
Menu::Tray => "Application tray".into(),
Menu::Network => match &s.network {
Some(n) if n.enabled => format!(
"Wi-Fi: {}",
@@ -202,6 +203,7 @@ pub(super) fn audio_icon(volume: u8, muted: bool) -> &'static [u8] {
pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
match kind {
Menu::Tray => (ferese_theme::icons::APPLICATION, true),
Menu::NightLight => (
ferese_theme::icons::MOON,
s.night_light.as_ref().is_some_and(|s| s.enabled),
+2 -2
View File
@@ -73,7 +73,7 @@ pub(super) fn view<'a>(
rows = rows.push(
shell
.render
.text("Power Profiles service unavailable")
.text(ferese_config::tr!("Power Profiles service unavailable"))
.size(12)
.class(theme::Text::Color(muted)),
);
@@ -136,7 +136,7 @@ pub(super) fn brightness<'a>(
} = style;
if let Some(value) = shell.status.brightness {
let brightness = column![
shell.render.text("Brightness").size(13),
shell.render.text(ferese_config::tr!("Brightness")).size(13),
slider_row(
shell.render,
ferese_theme::icons::BRIGHTNESS,
@@ -50,7 +50,7 @@ pub(super) fn view<'a>(
shell.render.text(device).size(14),
shell
.render
.text("Connected")
.text(ferese_config::tr!("Connected"))
.size(12)
.class(theme::Text::Color(muted)),
]
+21 -3
View File
@@ -18,9 +18,27 @@ pub(super) fn view<'a>(
render.font,
opacity,
[
menu_button(render, "‹", Message::CalendarMonth(-1), primary, opacity),
menu_button(render, "Today", Message::CalendarToday, primary, opacity),
menu_button(render, "›", Message::CalendarMonth(1), primary, opacity),
menu_button(
render,
ferese_config::tr!("‹"),
Message::CalendarMonth(-1),
primary,
opacity,
),
menu_button(
render,
ferese_config::tr!("Today"),
Message::CalendarToday,
primary,
opacity,
),
menu_button(
render,
ferese_config::tr!("›"),
Message::CalendarMonth(1),
primary,
opacity,
),
],
)
}
+1 -1
View File
@@ -82,7 +82,7 @@ pub(super) fn level_meter(
opacity: f32,
) -> Element<'static, cosmic::Action<Message>> {
let segment = move |amount: u16, filled: bool| {
container(render.text(""))
container(render.text(ferese_config::tr!("")))
.width(Length::FillPortion(amount))
.height(5)
.class(theme::Container::custom(move |_| container::Style {
+3 -3
View File
@@ -79,7 +79,7 @@ pub(super) fn bar<'a>(
width: Option<f32>,
) -> Element<'a, cosmic::Action<Message>> {
let Some(player) = &shell.media.snapshot.selected else {
return shell.render.text("").into();
return shell.render.text(ferese_config::tr!("")).into();
};
let foreground = color(if selected {
palette.accent
@@ -302,7 +302,7 @@ pub(super) fn hub<'a>(
return rows.push(
shell
.render
.text("No active player")
.text(ferese_config::tr!("No active player"))
.size(14)
.class(theme::Text::Color(style.muted)),
);
@@ -624,7 +624,7 @@ pub(super) fn view<'a>(
rows = rows.push(
shell
.render
.text("No active player")
.text(ferese_config::tr!("No active player"))
.size(13)
.class(theme::Text::Color(style.muted)),
);
+17 -13
View File
@@ -39,6 +39,7 @@ struct MenuStyle {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Menu {
Tray,
NightLight,
Activities,
Workspaces,
@@ -90,7 +91,8 @@ impl Menu {
}
fn title(self) -> &'static str {
match self {
ferese_config::locale::text(match self {
Self::Tray => "Application tray",
Self::NightLight => "Night light",
Self::Activities => "Activities",
Self::Workspaces => "Workspaces",
@@ -105,12 +107,12 @@ impl Menu {
Self::CaptureStatus => "Capture status",
Self::Media => "Now playing",
Self::Notifications => "Notifications",
}
})
}
fn available(self, status: &Snapshot) -> bool {
match self {
Self::NightLight => true,
Self::Tray | Self::NightLight => true,
Self::Network => status.network.is_some(),
Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(),
@@ -207,7 +209,7 @@ impl FereseShell {
{
rows = rows.push(toggle_row(
self.render,
"Wi-Fi",
ferese_config::tr!("Wi-Fi"),
network.enabled,
Action::Wifi(!network.enabled),
theme,
@@ -219,7 +221,7 @@ impl FereseShell {
{
rows = rows.push(toggle_row(
self.render,
"Mute",
ferese_config::tr!("Mute"),
audio.muted,
Action::Mute(!audio.muted),
theme,
@@ -227,6 +229,7 @@ impl FereseShell {
));
}
let mut rows = match kind {
Menu::Tray => rows.push(self.tray_menu()),
Menu::System => system::view(self, rows, style, true),
Menu::NightLight => night_light::view(self, rows, style),
Menu::Network => network::view(self, rows, style),
@@ -245,14 +248,14 @@ impl FereseShell {
if self.notifications.entries.is_empty() {
rows = rows.push(
self.render
.text("No notifications")
.text(ferese_config::tr!("No notifications"))
.size(13)
.class(theme::Text::Color(style.muted)),
);
} else {
rows = rows.push(menu_button(
self.render,
"Clear notifications",
ferese_config::tr!("Clear notifications"),
Message::ClearNotifications,
style.muted,
opacity,
@@ -269,7 +272,7 @@ impl FereseShell {
.width(Length::Fill),
menu_button(
self.render,
"×",
ferese_config::tr!("×"),
Message::RemoveNotification(notice.id),
style.muted,
opacity
@@ -322,7 +325,7 @@ impl FereseShell {
if !self.capture_status.active() {
rows = rows.push(
self.render
.text("No active screen sharing or OBS output")
.text(ferese_config::tr!("No active screen sharing or OBS output"))
.size(13)
.class(theme::Text::Color(style.muted)),
);
@@ -349,7 +352,7 @@ impl FereseShell {
if !kind.available(&self.status) {
rows = rows.push(
self.render
.text("Service unavailable")
.text(ferese_config::tr!("Service unavailable"))
.size(13)
.class(theme::Text::Color(style.muted)),
);
@@ -378,7 +381,7 @@ impl FereseShell {
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else {
return self.render.text("").into();
return self.render.text(ferese_config::tr!("")).into();
};
motion::frame_driven(
menu.motion.revision(),
@@ -405,7 +408,7 @@ impl FereseShell {
fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else {
return self.render.text("").into();
return self.render.text(ferese_config::tr!("")).into();
};
if menu.kind == Menu::Notifications && self.notifications.ready {
return cosmic::widget::autosize::autosize(
@@ -461,6 +464,7 @@ impl FereseShell {
rows = rows.push(heading);
}
rows = match kind {
Menu::Tray => rows.push(self.tray_menu()),
Menu::System => system::view(self, rows, style, false),
Menu::NightLight => night_light::view(self, rows, style),
Menu::Network => network::view(self, rows, style),
@@ -480,7 +484,7 @@ impl FereseShell {
if kind != Menu::Calendar && !kind.available(&self.status) {
rows = rows.push(
self.render
.text("Service unavailable")
.text(ferese_config::tr!("Service unavailable"))
.size(13)
.class(theme::Text::Color(muted)),
);
+1 -1
View File
@@ -50,7 +50,7 @@ pub(super) fn view<'a>(
row![
shell
.render
.text("Signal strength")
.text(ferese_config::tr!("Signal strength"))
.size(12)
.width(Length::Fill),
shell.render.text(strength).size(12)
@@ -8,7 +8,12 @@ pub(super) fn view<'a>(
style: MenuStyle,
) -> MenuRows<'a> {
let Some(status) = &shell.status.night_light else {
return rows.push(shell.render.text("Night light is unavailable").size(13));
return rows.push(
shell
.render
.text(ferese_config::tr!("Night light is unavailable"))
.size(13),
);
};
rows = rows.push(toggle_row(
shell.render,
@@ -29,7 +34,10 @@ pub(super) fn view<'a>(
))
.step(100u16)
.on_release(cosmic::Action::App(Message::CommitNightLightTemperature)),
shell.render.text("Lower temperatures are warmer").size(12),
shell
.render
.text(ferese_config::tr!("Lower temperatures are warmer"))
.size(12),
]
.spacing(8);
rows = rows.push(control_card(
@@ -42,14 +50,16 @@ pub(super) fn view<'a>(
rows = rows.push(
shell
.render
.text("No display supports night light")
.text(ferese_config::tr!("No display supports night light"))
.size(13),
);
} else if status.enabled && status.failed_outputs > 0 {
rows = rows.push(
shell
.render
.text("Night light could not be applied to some displays")
.text(ferese_config::tr!(
"Night light could not be applied to some displays"
))
.size(12),
);
}
+7 -4
View File
@@ -56,7 +56,7 @@ pub(super) fn view<'a>(
)
.opacity(p),
)
.name("Open Ferese Settings")
.name(ferese_config::tr!("Open Ferese Settings"))
.width(30)
.height(28)
.padding([5, 3])
@@ -67,7 +67,10 @@ pub(super) fn view<'a>(
);
modes = modes.push(cosmic::widget::tooltip(
settings,
shell.render.text("Open Ferese Settings").size(12),
shell
.render
.text(ferese_config::tr!("Open Ferese Settings"))
.size(12),
cosmic::widget::tooltip::Position::Left,
));
}
@@ -129,7 +132,7 @@ pub(super) fn view<'a>(
.center_y(28),
shell
.render
.text("Battery")
.text(ferese_config::tr!("Battery"))
.size(11)
.width(Length::Fill)
.height(16)
@@ -281,7 +284,7 @@ pub(super) fn heading<'a>(
color_with_opacity(theme.accent, p),
))
.padding(6)
.name("Open Settings")
.name(ferese_config::tr!("Open Settings"))
.on_press(cosmic::Action::App(Message::Control(Action::Settings))),
primary,
false,
+1 -1
View File
@@ -621,7 +621,7 @@ impl FereseShell {
pub(super) fn view_wallpaper(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(handle) = self.wallpaper.as_ref() else {
return container(self.render.text(""))
return container(self.render.text(ferese_config::tr!("")))
.width(Length::Fill)
.height(Length::Fill)
.class(theme::Container::custom(wallpaper_fallback_style))
+10 -8
View File
@@ -508,10 +508,10 @@ impl FereseShell {
fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else {
return self.render.text("").into();
return self.render.text(ferese_config::tr!("")).into();
};
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
return self.render.text("").into();
return self.render.text(ferese_config::tr!("")).into();
};
motion::frame_driven(
modal.motion.revision(),
@@ -542,10 +542,10 @@ impl FereseShell {
now: Instant,
) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else {
return self.render.text("").into();
return self.render.text(ferese_config::tr!("")).into();
};
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
return self.render.text("").into();
return self.render.text(ferese_config::tr!("")).into();
};
let progress = modal.motion.progress_at(now);
let content: Element<'_, cosmic::Action<Message>> = if surface.primary {
@@ -568,7 +568,9 @@ impl FereseShell {
{
rows = rows.push(
self.render
.text("Applications requested that this action be prevented:")
.text(ferese_config::tr!(
"Applications requested that this action be prevented:"
))
.size(13)
.class(theme::Text::Color(palette.muted)),
);
@@ -664,13 +666,13 @@ impl FereseShell {
.saturating_sub(360)
.clamp(40, 400) as f32;
column![
self.render.text("Welcome to Ferese").size(23),
self.render.text(ferese_config::tr!("Welcome to Ferese")).size(23),
ferese_theme::menus::section_label("Your active shortcuts", self.render.font, palette.muted,)
.size(14),
container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height),
self.render
.text(
"Disable this guide in Settings → Shortcuts. It appears at each login until disabled."
ferese_config::tr!("Disable this guide in Settings → Shortcuts. It appears at each login until disabled.")
)
.size(12)
.class(theme::Text::Color(palette.muted)),
@@ -718,7 +720,7 @@ impl FereseShell {
}))
.into()
} else {
self.render.text("").into()
self.render.text(ferese_config::tr!("")).into()
};
let content = if surface.primary {
motion::animated(
+509
View File
@@ -0,0 +1,509 @@
//! StatusNotifierItem host on the session bus. The watcher stays subscribed when
//! icons are hidden so applications reappear immediately when enabled again.
use crate::{FereseShell, Message, status_ui};
use cosmic::{
Element,
app::Task,
iced::{Alignment, Length, Subscription},
widget::{button, column, container, icon, image, row},
};
use ferese_config::locale::text as t;
use std::sync::Arc;
use system_tray::{
client::{ActivateRequest, Client},
item::{Status, StatusNotifierItem},
menu::MenuItem,
};
#[derive(Clone, Debug)]
pub enum Event {
Snapshot(
Arc<Client>,
Vec<(
String,
StatusNotifierItem,
Option<system_tray::menu::TrayMenu>,
)>,
),
Disconnected,
}
#[derive(Clone, Debug)]
pub enum Input {
Locate(String, cosmic::iced::window::Id, cosmic::widget::Id),
Located(
String,
cosmic::iced::window::Id,
Option<cosmic::iced::Rectangle>,
),
Activate(String),
Context(
String,
cosmic::iced::window::Id,
cosmic::iced::Rectangle<i32>,
),
Menu(String, i32),
Manage,
Done(Result<(), String>),
}
#[derive(Default)]
pub struct Model {
client: Option<Arc<Client>>,
items: Vec<Entry>,
current: Option<String>,
expanded: std::collections::HashSet<i32>,
}
struct Entry {
address: String,
item: StatusNotifierItem,
menu: Option<system_tray::menu::TrayMenu>,
pixmap: Option<image::Handle>,
}
impl Model {
pub fn update(&mut self, event: Event) {
match event {
Event::Snapshot(client, items) => {
self.client = Some(client);
self.items = items
.into_iter()
.map(|(address, item, menu)| {
let pixmap = pixmap(&item);
Entry {
address,
item,
menu,
pixmap,
}
})
.collect();
self.items.sort_by(|a, b| a.item.id.cmp(&b.item.id));
}
Event::Disconnected => {
self.client = None;
self.items.clear();
self.current = None;
}
}
}
pub fn available(&self, config: &ferese_config::status::Tray) -> bool {
self.visible(config).next().is_some()
}
fn visible<'a>(
&'a self,
config: &'a ferese_config::status::Tray,
) -> impl Iterator<Item = &'a Entry> {
self.items.iter().filter(move |e| {
config.enabled
&& (config.show_passive || e.item.status != Status::Passive)
&& !config.hidden_items.contains(&e.item.id)
})
}
}
pub fn subscription() -> Subscription<Event> {
Subscription::run(|| {
cosmic::iced::stream::channel(16, async move |mut out| {
use cosmic::iced::futures::SinkExt;
loop {
if let Ok(client) = Client::new().await {
let client = Arc::new(client);
let mut receiver = client.subscribe();
loop {
let items = client
.items()
.lock()
.map(|m| {
m.iter()
.map(|(a, (i, m))| (a.clone(), i.clone(), m.clone()))
.collect()
})
.unwrap_or_default();
if out
.send(Event::Snapshot(client.clone(), items))
.await
.is_err()
{
return;
}
match receiver.recv().await {
Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
Err(_) => break,
}
}
}
if out.send(Event::Disconnected).await.is_err() {
return;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
})
})
}
fn pixmap(item: &StatusNotifierItem) -> Option<image::Handle> {
let data = if item.status == Status::NeedsAttention {
item.attention_icon_pixmap
.as_ref()
.or(item.icon_pixmap.as_ref())
} else {
item.icon_pixmap.as_ref()
}?;
let p = data
.iter()
.filter(|p| {
p.width > 0
&& p.height > 0
&& p.width <= 512
&& p.height <= 512
&& p.pixels.len() == p.width as usize * p.height as usize * 4
})
.min_by_key(|p| (p.width - 32).abs())?;
let mut rgba = p.pixels.clone();
for p in rgba.chunks_exact_mut(4) {
p.rotate_left(1)
}
Some(image::Handle::from_rgba(
p.width as u32,
p.height as u32,
rgba,
))
}
impl FereseShell {
pub(crate) fn view_tray(
&self,
id: cosmic::iced::window::Id,
) -> Element<'_, cosmic::Action<Message>> {
let size = self.config.status.tray.icon_size;
let mut icons = row([]).spacing(4).align_y(Alignment::Center);
for e in self.tray.visible(&self.config.status.tray) {
let visual: Element<'_, cosmic::Action<Message>> = if let Some(p) = &e.pixmap {
image(p.clone()).width(size).height(size).into()
} else if let Some(name) = e.item.icon_name.as_ref().filter(|n| !n.is_empty()) {
icon(icon::from_name(name.clone()).size(size as u16).handle())
.size(size as u16)
.into()
} else {
self.render.text(ferese_config::tr!("●")).size(size).into()
};
let address = e.address.clone();
let title = e.item.title.as_deref().unwrap_or(&e.item.id);
let control = button::custom(container(visual).center_y(size + 8.))
.padding([0, 3])
.name(title.to_owned())
.on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::TrayInput(Input::Context(
address.clone(),
id,
cosmic::iced::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,
},
)))
});
// Left click activates normal applications; menu-only indicators open their menu.
let control = if !e.item.item_is_menu {
control.on_press(cosmic::Action::App(Message::TrayInput(Input::Activate(
e.address.clone(),
))))
} else {
control
};
let address = e.address.clone();
let target = cosmic::widget::Id::unique();
let control = cosmic::widget::mouse_area(container(control).id(target.clone()))
.on_right_press(cosmic::Action::App(Message::TrayInput(Input::Locate(
address, id, target,
))));
// An ordinary tooltip is clipped by a layer bar and can cover the
// icon. Keep its accessible name; details live in the popover.
icons = icons.push(control);
}
icons.into()
}
pub(crate) fn tray_input(&mut self, input: Input) -> Task<Message> {
match input {
Input::Locate(address, parent, target) => {
cosmic::iced::advanced::widget::operate(FindBounds {
target,
parent,
window: None,
found: None,
})
.map(move |rect| {
cosmic::Action::App(Message::TrayInput(Input::Located(
address.clone(),
parent,
rect,
)))
})
}
Input::Located(address, parent, Some(rect)) => self.tray_input(Input::Context(
address,
parent,
cosmic::iced::Rectangle {
x: rect.x as i32,
y: rect.y as i32,
width: rect.width as i32,
height: rect.height as i32,
},
)),
Input::Located(_, _, None) => Task::none(),
Input::Context(address, parent, mut rect) => {
self.tray.current = Some(address.clone());
self.tray.expanded.clear();
if rect.x == 0 && rect.y == 0 {
rect.x = 20;
rect.y = 20;
}
let about = self.tray.client.clone().zip(
self.tray
.items
.iter()
.find(|i| i.address == address)
.and_then(|i| i.item.menu.clone()),
);
let task = if let Some((client, path)) = about {
cosmic::task::future(async move {
Message::TrayInput(Input::Done(
client
.about_to_show_menuitem(address, path, 0)
.await
.map(|_| ())
.map_err(|e| e.to_string()),
))
})
} else {
Task::none()
};
Task::batch([
task,
self.open_menu(
status_ui::Menu::Tray,
status_ui::PopoverAnchor {
parent,
panel: self.panel_for_bar(parent).id.clone(),
item: None,
rectangle: rect,
},
),
])
}
Input::Manage => {
let command = self
.config
.status
.settings_command
.clone()
.unwrap_or_else(|| vec!["ferese-settings".into()]);
if let Some(exe) = command.first() {
let _ = std::process::Command::new(exe)
.args(&command[1..])
.args(["--page", "tray"])
.spawn();
}
self.close_menu()
}
Input::Activate(address) => {
if let Some(client) = self.tray.client.clone() {
cosmic::task::future(async move {
Message::TrayInput(Input::Done(
client
.activate(ActivateRequest::Default {
address,
x: 0,
y: 0,
})
.await
.map_err(|e| e.to_string()),
))
})
} else {
Task::none()
}
}
Input::Menu(address, id) => {
if self
.tray
.items
.iter()
.filter(|e| e.address == address)
.filter_map(|e| e.menu.as_ref())
.flat_map(|m| m.submenus.iter())
.any(|m| contains_submenu(m, id))
{
if !self.tray.expanded.remove(&id) {
self.tray.expanded.insert(id);
}
if let Some((client, path)) = self.tray.client.clone().zip(
self.tray
.items
.iter()
.find(|e| e.address == address)
.and_then(|e| e.item.menu.clone()),
) {
return cosmic::task::future(async move {
Message::TrayInput(Input::Done(
client
.about_to_show_menuitem(address, path, id)
.await
.map(|_| ())
.map_err(|e| e.to_string()),
))
});
}
return Task::none();
}
let menu_path = self
.tray
.items
.iter()
.find(|e| e.address == address)
.and_then(|e| e.item.menu.clone());
if let Some((client, menu_path)) = self.tray.client.clone().zip(menu_path) {
let task = cosmic::task::future(async move {
Message::TrayInput(Input::Done(
client
.activate(ActivateRequest::MenuItem {
address,
menu_path,
submenu_id: id,
})
.await
.map_err(|e| e.to_string()),
))
});
Task::batch([task, self.close_menu()])
} else {
Task::none()
}
}
Input::Done(result) => {
if let Err(e) = result {
eprintln!("tray action: {e}");
}
Task::none()
}
}
}
pub(crate) fn tray_menu(&self) -> Element<'_, cosmic::Action<Message>> {
let mut rows = column([]).spacing(6);
if let Some(e) = self
.tray
.current
.as_ref()
.and_then(|address| self.tray.items.iter().find(|e| &e.address == address))
{
if let Some(menu) = &e.menu {
for item in &menu.submenus {
rows = rows.push(self.tray_menu_item(&e.address, item, 0));
}
}
rows = rows.push(
button::custom(self.render.text(t("Manage tray icons")).size(12))
.padding(8)
.on_press(cosmic::Action::App(Message::TrayInput(Input::Manage))),
);
}
rows.into()
}
fn tray_menu_item<'a>(
&'a self,
address: &str,
item: &'a MenuItem,
depth: usize,
) -> Element<'a, cosmic::Action<Message>> {
if !item.visible || depth > 8 {
return cosmic::widget::Space::new().height(0).into();
}
let title = item
.label
.as_deref()
.unwrap_or("")
.replace("__", "\0")
.replace('_', "")
.replace('\0', "_");
let mut rows = column([]).spacing(3);
rows = rows.push(
button::custom(
self.render
.text(format!(
"{}{}",
title,
if item.submenu.is_empty()
&& item.children_display.as_deref() != Some("submenu")
{
""
} else {
" ›"
}
))
.size(13),
)
.padding(8)
.width(Length::Fill)
.on_press_maybe(item.enabled.then(|| {
cosmic::Action::App(Message::TrayInput(Input::Menu(address.into(), item.id)))
})),
);
if self.tray.expanded.contains(&item.id) {
for sub in &item.submenu {
rows = rows
.push(container(self.tray_menu_item(address, sub, depth + 1)).padding([0, 12]));
}
}
rows.into()
}
}
fn contains_submenu(item: &MenuItem, id: i32) -> bool {
(item.id == id
&& (!item.submenu.is_empty() || item.children_display.as_deref() == Some("submenu")))
|| item.submenu.iter().any(|s| contains_submenu(s, id))
}
#[cfg(test)]
mod tests {
#[test]
fn lazy_submenus_are_detected_without_children() {
let item = system_tray::menu::MenuItem {
id: 7,
children_display: Some("submenu".into()),
..Default::default()
};
assert!(super::contains_submenu(&item, 7));
assert!(!super::contains_submenu(&item, 8));
}
#[test]
fn argb_conversion_keeps_alpha() {
let mut argb = [128, 2, 3, 4];
argb.rotate_left(1);
assert_eq!(argb, [2, 3, 4, 128]);
}
}
struct FindBounds {
target: cosmic::widget::Id,
parent: cosmic::iced::window::Id,
window: Option<cosmic::iced::window::Id>,
found: Option<cosmic::iced::Rectangle>,
}
impl cosmic::iced::advanced::widget::Operation<Option<cosmic::iced::Rectangle>> for FindBounds {
fn traverse(
&mut self,
children: &mut dyn FnMut(
&mut dyn cosmic::iced::advanced::widget::Operation<Option<cosmic::iced::Rectangle>>,
),
) {
children(self);
}
fn set_window_id(&mut self, id: cosmic::iced::window::Id) {
self.window = Some(id);
}
fn container(&mut self, id: Option<&cosmic::widget::Id>, bounds: cosmic::iced::Rectangle) {
if self.window == Some(self.parent) && id == Some(&self.target) {
self.found = Some(bounds);
}
}
fn finish(
&self,
) -> cosmic::iced::advanced::widget::operation::Outcome<Option<cosmic::iced::Rectangle>> {
cosmic::iced::advanced::widget::operation::Outcome::Some(self.found)
}
}
+1 -1
View File
@@ -329,7 +329,7 @@ impl FereseShell {
.iter()
.find(|workspace| workspace.id == hover.workspace)
}) else {
return self.render.text("").into();
return self.render.text(ferese_config::tr!("")).into();
};
let active = self
.snapshot