Unify shell opacity and add native authentication and system controls

This commit is contained in:
Samuel Onoja committed 2026-09-28 09:26:14 +01:00
1 parent 8832a9bf2e
commit c8e89fb758
35 files changed
+1674 -133

No files matched your search

+34 -40
View File
@@ -62,11 +62,11 @@ pub(crate) struct ShellTheme {
impl Default for ShellTheme {
fn default() -> Self {
Self {
bar_background: [28, 32, 46, 242],
bar_background: [28, 32, 46, 255],
bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255],
surface_base: [17, 24, 33, 255],
surface_popover: [17, 24, 33, 245],
surface_popover: [17, 24, 33, 255],
text_primary: [244, 247, 251, 255],
text_muted: [135, 147, 162, 255],
accent: [61, 123, 230, 255],
@@ -148,6 +148,8 @@ struct AppearanceConfig {
#[derive(Debug, Default, Deserialize)]
struct ThemeConfig {
#[serde(default)]
material: MaterialConfig,
#[serde(default)]
surface: SurfaceConfig,
#[serde(default)]
@@ -166,16 +168,12 @@ struct ThemeConfig {
struct SurfaceConfig {
#[serde(default)]
bar: BarConfig,
#[serde(default)]
popover: PopoverConfig,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
struct BarConfig {
background: String,
// If omitted, preserve the alpha supplied in `background`.
opacity: Option<f32>,
text_primary: String,
text_muted: String,
}
@@ -184,29 +182,17 @@ impl Default for BarConfig {
fn default() -> Self {
Self {
background: "#1C202EF2".to_owned(),
opacity: None,
text_primary: "#F0F3FA".to_owned(),
text_muted: "#AAB4C7".to_owned(),
}
}
}
#[derive(Debug, Deserialize)]
struct PopoverConfig {
#[serde(default = "default_popover_opacity")]
opacity: f32,
}
impl Default for PopoverConfig {
fn default() -> Self {
Self {
opacity: default_popover_opacity(),
}
}
}
const fn default_popover_opacity() -> f32 {
0.96
#[derive(Debug, Default, Deserialize)]
struct MaterialConfig {
#[serde(default)]
style: String,
opacity: Option<f32>,
}
#[derive(Debug, Default, Deserialize)]
@@ -364,17 +350,26 @@ pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::E
fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
let defaults = ShellTheme::default();
let opacity = if theme.material.style == "translucent" {
finite_or(
theme
.material
.opacity
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY as f32),
ferese_config::DEFAULT_MATERIAL_OPACITY as f32,
)
.clamp(0.0, 1.0)
} else {
1.0
};
let alpha = (opacity * 255.0).round() as u8;
ShellTheme {
material_radius: defaults.material_radius,
bar_background: {
let mut background =
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
if let Some(opacity) = theme.surface.bar.opacity {
background[3] =
(finite_or(opacity, f32::from(background[3]) / 255.0).clamp(0.0, 1.0) * 255.0)
.round() as u8;
}
background[3] = alpha;
background
},
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
@@ -385,10 +380,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
surface_popover: {
let mut color =
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = (finite_or(theme.surface.popover.opacity, default_popover_opacity())
.clamp(0.0, 1.0)
* 255.0)
.round() as u8;
color[3] = alpha;
color
},
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
@@ -617,7 +609,7 @@ theme {
assert_eq!(theme.bar_height, 28.0);
assert_eq!(theme.bar_margin_horizontal, 0);
assert_eq!(theme.bar_radius, 14.0);
assert_eq!(theme.bar_background, ShellTheme::default().bar_background);
assert_eq!(theme.bar_background[3], 255);
assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary);
assert_ne!(theme.for_bar().text_primary, theme.text_primary);
}
@@ -638,21 +630,17 @@ theme {
)
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background, [234, 236, 238, 221]);
assert_eq!(theme.bar_background, [234, 236, 238, 255]);
assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]);
assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]);
assert_eq!(theme.text_primary, ShellTheme::default().text_primary);
}
#[test]
fn bar_transparency_and_shadow_opacity_are_independent() {
fn shell_opacity_is_shared_and_solid_remains_opaque() {
let config: FereseConfig = ferese_config::from_str(
r#"theme {
surface {
bar {
opacity 0.6
}
}
material { style "translucent"; opacity 0.6; }
shadow {
soft {
opacity 0.07
@@ -664,6 +652,12 @@ theme {
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background[3], 153);
assert_eq!(theme.surface_popover[3], 153);
assert_eq!(theme.shadow_opacity, 0.07);
let mut config = config;
config.theme.material.style = "solid".into();
let solid = shell_theme(&config.theme);
assert_eq!(solid.bar_background[3], 255);
assert_eq!(solid.surface_popover[3], 255);
}
}
+136 -3
View File
@@ -59,12 +59,15 @@ fn configured_font(family: Option<&str>) -> cosmic::font::Font {
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
> = std::sync::OnceLock::new();
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
if let Some(font) = fonts.get(family) {
return *font;
}
if fonts.len() >= 64 {
return cosmic::font::default();
}
let family = Box::leak(family.to_owned().into_boxed_str());
let font = cosmic::font::Font::with_name(family);
fonts.insert(family.to_owned(), font);
@@ -113,14 +116,17 @@ impl From<ShellTheme> for BarMetrics {
fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
let mut parts = label.split_whitespace();
let time = parts.next()?;
if let Some(period) = parts.next() {
if !period.eq_ignore_ascii_case("am") && !period.eq_ignore_ascii_case("pm") {
return None;
}
}
if parts.next().is_some() {
return None;
}
let (hour, minute) = time.split_once(':')?;
if !(1..=2).contains(&hour.len())
|| minute.len() != 2
@@ -131,6 +137,7 @@ fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
{
return None;
}
Some([format!("{hour:0>2}"), minute.to_owned()])
}
@@ -142,6 +149,7 @@ fn main() -> cosmic::iced::Result {
// toolkit threads are started.
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
}
cosmic::iced::advanced::graphics::text::font_system()
.write()
.unwrap()
@@ -154,6 +162,7 @@ fn main() -> cosmic::iced::Result {
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
"../../../assets/fonts/Cantarell-ExtraBold.otf"
)));
let mut config = config::load();
motion::configure(config.animations, config.theme.material_radius);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
@@ -207,6 +216,7 @@ struct FereseShell {
status_service: status::Service,
status: status::Snapshot,
recorder: recording::Recorder,
calendar_offset: i32,
status_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
note_editor: Option<DesktopNoteEditor>,
@@ -284,6 +294,8 @@ enum Message {
Control(status::Action),
ConfirmPower(status::Action),
CancelPower,
CalendarMonth(i32),
CalendarToday,
}
impl cosmic::Application for FereseShell {
@@ -316,6 +328,7 @@ impl cosmic::Application for FereseShell {
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
recorder: recording::Recorder::default(),
calendar_offset: 0,
status_error: None,
menu: None,
note_editor: None,
@@ -348,6 +361,7 @@ impl cosmic::Application for FereseShell {
}),
None => Task::none(),
};
(app, wallpaper_task)
}
@@ -420,22 +434,28 @@ impl cosmic::Application for FereseShell {
if let Err(error) = effects.set_opacity(menu.progress()) {
eprintln!("ferese-shell: could not update popup opacity: {error}");
}
let regions = menu.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update card materials: {error}");
}
}
if let Some(surface) = &self.notification_surface
&& let Some(effects) = &surface.effects
{
let regions = surface.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update notification materials: {error}");
}
}
match message {
Message::BeginNoteEdit(id) => {
let mut tasks = vec![self.finish_note_edit()];
if let Some(note) = self
.config
.desktop_widgets
@@ -459,6 +479,7 @@ impl cosmic::Application for FereseShell {
{
let edited = action.is_edit();
editor.content.perform(action);
if edited {
editor.revision = editor.revision.wrapping_add(1);
let revision = editor.revision;
@@ -525,6 +546,7 @@ impl cosmic::Application for FereseShell {
),
}
}
if let Some(menu) = &mut self.menu
&& menu.id == id
&& menu.effects.is_none()
@@ -539,6 +561,7 @@ impl cosmic::Application for FereseShell {
eprintln!("ferese-shell: popover material unavailable: {error}")
}
}
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
@@ -566,6 +589,7 @@ impl cosmic::Application for FereseShell {
self.status_error = update.error;
}
self.notifications.tick();
if self.notifications.ready {
self.status.notifications = Some(status::Notifications {
count: self.notifications.unread(),
@@ -636,6 +660,14 @@ impl cosmic::Application for FereseShell {
}
Task::none()
}
Message::CalendarMonth(delta) => {
self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200);
Task::none()
}
Message::CalendarToday => {
self.calendar_offset = 0;
Task::none()
}
Message::CancelPower => {
if let Some(menu) = &mut self.menu {
menu.confirm = None;
@@ -643,6 +675,18 @@ impl cosmic::Application for FereseShell {
Task::none()
}
Message::Control(action) => {
if let status::Action::PowerProfile(profile) = action {
self.status_error = self
.status_service
.send(status::Action::PowerProfile(profile))
.err();
if self.status_error.is_none() {
if let Some(profiles) = &mut self.status.power_profiles {
profiles.active = profile.to_owned();
}
}
return Task::none();
}
if self.notifications.ready {
match action {
status::Action::Dnd(value) => {
@@ -774,9 +818,11 @@ impl FereseShell {
return Task::none();
}
};
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
config.wallpaper.path = None;
}
if self.config.font_family != config.font_family {
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
@@ -784,6 +830,7 @@ impl FereseShell {
.unwrap() = configured_font(config.font_family.as_deref());
}
motion::configure(config.animations, config.theme.material_radius);
if self.config.animations != config.animations
&& let Some(menu) = &mut self.menu
{
@@ -791,6 +838,7 @@ impl FereseShell {
}
self.status_service
.update_settings(config.status.settings_command.clone());
let old = self.config.theme;
let old_clock = &self.config.desktop_widgets.clock;
let old_notes = &self.config.desktop_widgets.notes;
@@ -826,6 +874,7 @@ impl FereseShell {
} else {
Task::none()
}];
if clock_changed
&& self
.note_drag
@@ -834,6 +883,7 @@ impl FereseShell {
{
tasks.push(self.finish_note_drag(false));
}
if notes_changed {
tasks.push(self.finish_note_drag(false));
if self.note_editor.as_ref().is_some_and(|editor| {
@@ -848,6 +898,7 @@ impl FereseShell {
}
tasks.push(self.rebuild_notes(true));
}
for entry in self.outputs.iter().filter(|_| geometry_changed) {
tasks.push(set_size(
entry.bar,
@@ -866,6 +917,7 @@ impl FereseShell {
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap),
));
}
Task::batch(tasks)
}
@@ -882,6 +934,7 @@ impl FereseShell {
} else {
Task::none()
};
let mut tasks = vec![destroy_layer_surface(entry.bar), menu];
if self.note_drag.as_ref().is_some_and(|drag| {
entry.clock == Some(drag.source)
@@ -889,20 +942,26 @@ impl FereseShell {
}) {
tasks.push(self.finish_note_drag(false));
}
if let Some(wallpaper) = entry.wallpaper {
tasks.push(destroy_layer_surface(wallpaper));
}
if let Some(clock) = entry.clock {
tasks.push(destroy_layer_surface(clock));
}
for (_, id) in entry.notes {
self.note_pointer.remove(&id);
tasks.push(destroy_layer_surface(id));
}
return Task::batch(tasks);
}
return Task::none();
}
let size = match &event {
wayland::OutputEvent::Created(info) => info.as_ref().and_then(|info| info.logical_size),
wayland::OutputEvent::InfoUpdate(info) => info.logical_size,
@@ -913,20 +972,24 @@ impl FereseShell {
wayland::OutputEvent::InfoUpdate(info) => info.name,
_ => None,
};
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) {
if size.is_some() {
entry.size = size;
}
let changed = name.is_some() && entry.name != name;
if name.is_some() {
entry.name = name;
}
return if changed {
Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)])
} else {
Task::none()
};
}
let bar_surface_id = window::Id::unique();
let wallpaper_surface_id = window::Id::unique();
let shell_theme = self.config.theme;
@@ -1005,6 +1068,7 @@ impl FereseShell {
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() {
surfaces.push(wallpaper_action);
}
let tasks = surfaces
.into_iter()
.map(cosmic::Action::Surface)
@@ -1021,6 +1085,7 @@ impl FereseShell {
if self.note_saving || self.note_pending.is_empty() {
return Task::none();
}
let Some(path) = config::config_path() else {
self.note_error = Some("Configuration path unavailable.".into());
return Task::none();
@@ -1109,6 +1174,7 @@ impl FereseShell {
let output = entry.output.clone();
let mut obstacles = Vec::new();
let clock = &self.config.desktop_widgets.clock;
if id.is_some() && clock.on_output(entry.name.as_deref()) {
let position = widget_origin(
clock.anchor,
@@ -1122,6 +1188,7 @@ impl FereseShell {
cosmic::iced::Size::new(clock.width as f32, clock.height as f32),
));
}
for note in &self.config.desktop_widgets.notes {
if Some(&note.id) != id.as_ref() && note.on_output(entry.name.as_deref()) {
obstacles.push(cosmic::iced::Rectangle::new(
@@ -1130,6 +1197,7 @@ impl FereseShell {
));
}
}
let overlay = window::Id::unique();
self.note_drag = Some(NoteDrag {
id,
@@ -1161,8 +1229,10 @@ impl FereseShell {
size_limits: Limits::NONE,
..Default::default()
},
Some(Box::new(Self::view_note_drag)),
);
Task::batch([
self.finish_note_edit(),
cosmic::task::message(cosmic::Action::Surface(action)),
@@ -1173,6 +1243,7 @@ impl FereseShell {
let Some(drag) = &self.note_drag else {
return text("").into();
};
if let Some(id) = &drag.id {
self.view_note_content(id, drag.source)
} else {
@@ -1185,6 +1256,7 @@ impl FereseShell {
return Task::none();
};
let mut tasks = vec![destroy_layer_surface(drag.overlay)];
if save {
if drag.id.is_none() {
let clock = &mut self.config.desktop_widgets.clock;
@@ -1202,6 +1274,7 @@ impl FereseShell {
clock.margin_y,
));
}
if let Some(note) = self
.config
.desktop_widgets
@@ -1212,6 +1285,7 @@ impl FereseShell {
note.anchor = ferese_core::desktop::Anchor::TopLeft;
note.margin_x = drag.position.x.round() as i32;
note.margin_y = drag.position.y.round() as i32;
for entry in &self.outputs {
if let Some((_, id)) = entry
.notes
@@ -1222,12 +1296,14 @@ impl FereseShell {
tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x));
}
}
self.note_pending.push(note_store::Edit::Position(
note.id.clone(),
note.margin_x,
note.margin_y,
));
}
tasks.push(self.flush_note_changes());
}
Task::batch(tasks)
@@ -1235,6 +1311,7 @@ impl FereseShell {
fn rebuild_notes(&mut self, reset: bool) -> Task<Message> {
let mut tasks = Vec::new();
for entry in &mut self.outputs {
if reset {
for (_, id) in entry.notes.drain(..) {
@@ -1242,12 +1319,14 @@ impl FereseShell {
tasks.push(destroy_layer_surface(id));
}
}
for note in &self.config.desktop_widgets.notes {
if !note.on_output(entry.name.as_deref())
|| entry.notes.iter().any(|(id, _)| id == &note.id)
{
continue;
}
let id = window::Id::unique();
entry.notes.push((note.id.clone(), id));
let note = note.clone();
@@ -1281,11 +1360,14 @@ impl FereseShell {
..Default::default()
}
},
Some(Box::new(move |app| app.view_note(&note_id, id))),
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
}
Task::batch(tasks)
}
@@ -1300,6 +1382,7 @@ impl FereseShell {
.height(Length::Fill)
.into();
}
self.view_note_content(id, surface)
}
@@ -1334,7 +1417,9 @@ impl FereseShell {
.and_then(config::parse_color)
.unwrap_or(self.config.theme.text_primary),
);
foreground.a *= note.opacity;
let background = if note.background.as_deref() == Some("") {
None
} else {
@@ -1350,6 +1435,7 @@ impl FereseShell {
let mut body = cosmic::widget::column([])
.spacing(note.gap)
.align_x(alignment);
if note.interactive {
let title = cosmic::widget::text(if note.title.is_empty() {
"⋮⋮"
@@ -1369,6 +1455,7 @@ impl FereseShell {
.note_editor
.as_ref()
.is_some_and(|editor| editor.id == id);
body = body.push(
row([]).push(header).push(motion::button(
button::custom(text(if editing { "Done" } else { "Edit" }))
@@ -1396,6 +1483,7 @@ impl FereseShell {
.class(theme::Text::Color(foreground)),
);
}
if let Some(editor) = &self.note_editor
&& editor.id == id
{
@@ -1425,6 +1513,7 @@ impl FereseShell {
.class(theme::Text::Color(foreground)),
);
}
if let Some(error) = &self.note_error {
body = body.push(
text(error.clone())
@@ -1432,7 +1521,9 @@ impl FereseShell {
.class(theme::Text::Color(foreground)),
);
}
let radius = self.config.theme.material_radius;
container(body)
.padding(note.padding)
.width(Length::Fill)
@@ -1456,18 +1547,23 @@ impl FereseShell {
.labels(&Zoned::now())
.unwrap_or_default();
let mut tasks = Vec::new();
for entry in &mut self.outputs {
if !reset && entry.clock.is_some() {
continue;
}
if let Some(id) = entry.clock.take() {
self.note_pointer.remove(&id);
tasks.push(destroy_layer_surface(id));
}
let clock = self.config.desktop_widgets.clock.clone();
if !clock.on_output(entry.name.as_deref()) {
continue;
}
let id = window::Id::unique();
entry.clock = Some(id);
let output = entry.output.clone();
@@ -1490,10 +1586,13 @@ impl FereseShell {
..Default::default()
}
},
Some(Box::new(move |app| app.view_desktop_clock(id))),
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
Task::batch(tasks)
}
@@ -1508,6 +1607,7 @@ impl FereseShell {
.height(Length::Fill)
.into();
}
cosmic::widget::mouse_area(self.view_clock_content())
.on_press(cosmic::Action::App(Message::BeginClockDrag(id)))
.interaction(cosmic::iced::mouse::Interaction::Grab)
@@ -1602,6 +1702,7 @@ impl FereseShell {
for (line, digits) in digits.into_iter().enumerate() {
let mut digit_row = row([]).spacing(0);
for (position, digit) in digits.chars().enumerate() {
digit_row = digit_row.push(
cosmic::widget::text(digit.to_string())
@@ -1611,8 +1712,10 @@ impl FereseShell {
.class(theme::Text::Color(digit_colors[(line + position) % 2])),
);
}
stack = stack.push(digit_row);
}
labels = labels.push(stack);
} else {
let characters = self.desktop_clock.0.chars().count().max(1) as f32;
@@ -1629,6 +1732,7 @@ impl FereseShell {
.time_size
.min(available_width / (characters * 0.75))
.min(available_height / 1.3);
labels = labels.push(
cosmic::widget::text(self.desktop_clock.0.clone())
.font(font)
@@ -1662,6 +1766,7 @@ impl FereseShell {
Background::Color(tint)
});
let radius = self.config.theme.material_radius;
container(labels)
.padding(clock.padding)
.width(Length::Fill)
@@ -1706,9 +1811,11 @@ impl FereseShell {
}) {
return Task::none();
}
match mouse {
cosmic::iced::mouse::Event::CursorMoved { position } => {
self.note_pointer.insert(id, *position);
if let Some(drag) = &mut self.note_drag
&& drag.source == id
{
@@ -1724,6 +1831,7 @@ impl FereseShell {
let clock = &self.config.desktop_widgets.clock;
Some((clock.width, clock.height))
};
if let Some(dimensions) = dimensions {
let requested = clamp_note_position(
drag.origin + delta,
@@ -1748,6 +1856,7 @@ impl FereseShell {
}
}
}
cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) => {
if self
.note_drag
@@ -1770,13 +1879,16 @@ impl FereseShell {
if self.note_drag.is_some() {
return self.finish_note_drag(false);
}
if self.note_editor.is_some() {
return self.finish_note_edit();
}
if self.notifications.history_open {
return self.close_notification_history();
}
}
match event {
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
event,
@@ -1800,6 +1912,7 @@ impl FereseShell {
let _ = effects.set_opacity(menu.progress());
}
}
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
}
@@ -1978,6 +2091,7 @@ impl FereseShell {
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
.spacing(1)
.align_y(cosmic::iced::Alignment::Center);
for workspace in &self.snapshot.workspaces {
let active = workspace_active_on_bar(workspace.id, focused_output);
let owner = workspace
@@ -2040,9 +2154,28 @@ impl FereseShell {
let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock));
let clock = container(
row![
text(date)
.size(12)
.class(theme::Text::Color(color(shell_theme.text_muted))),
motion::button(
button::custom(text(date).size(12))
.padding([4, 2])
.name("Open calendar")
.on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::OpenMenuOn(
id,
status_ui::Menu::Calendar,
cosmic::iced::Rectangle {
x: (bounds.x - offset.x).round() as i32,
y: (bounds.y - offset.y).round() as i32,
width: bounds.width.round() as i32,
height: bounds.height.round() as i32,
},
))
}),
color(shell_theme.text_muted),
self.menu
.as_ref()
.is_some_and(|menu| menu.kind == status_ui::Menu::Calendar),
1.0
),
text(time)
.size(bar.text_size)
.class(theme::Text::Color(foreground)),
+8
View File
@@ -48,6 +48,7 @@ impl Recorder {
pub fn busy(&self) -> bool {
self.updates.is_some()
}
pub fn elapsed(&self) -> Option<String> {
let State::Recording(start) = self.state else {
return None;
@@ -55,15 +56,18 @@ impl Recorder {
let seconds = start.elapsed().as_secs();
Some(format!("{:02}:{:02}", seconds / 60, seconds % 60))
}
pub fn start(&mut self) {
if self.busy() {
return;
}
let program = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("ferese-record")))
.filter(|p| p.is_file())
.unwrap_or_else(|| PathBuf::from("ferese-record"));
match Command::new(program)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -92,6 +96,7 @@ impl Recorder {
}
}
}
let _ = child.wait();
let _ = send.send(Event::Exited);
});
@@ -103,6 +108,7 @@ impl Recorder {
}
}
}
pub fn stop(&mut self) {
if self.busy() && !matches!(self.state, State::Saving) {
// EOF requests EOS/finalization. Never terminate the encoder abruptly.
@@ -110,6 +116,7 @@ impl Recorder {
self.state = State::Saving;
}
}
pub fn poll(&mut self) {
let mut done = false;
if let Some(updates) = &self.updates {
@@ -146,6 +153,7 @@ impl Recorder {
}
}
}
if done {
match &self.state {
State::Saved(path) => notify("Recording saved", path.display().to_string()),
+94
View File
@@ -21,6 +21,7 @@ pub struct Snapshot {
pub bluetooth: Option<Bluetooth>,
pub audio: Option<Audio>,
pub battery: Option<Battery>,
pub power_profiles: Option<PowerProfiles>,
pub brightness: Option<u8>,
pub notifications: Option<Notifications>,
pub poweroff: bool,
@@ -54,6 +55,12 @@ pub struct Battery {
pub status: String,
}
#[derive(Clone, Debug)]
pub struct PowerProfiles {
pub active: String,
pub available: [bool; 3],
}
#[derive(Clone, Debug)]
pub struct Notifications {
pub count: u32,
@@ -73,6 +80,7 @@ pub enum Action {
Poweroff,
Reboot,
Suspend,
PowerProfile(&'static str),
}
impl Action {
@@ -143,11 +151,13 @@ impl Service {
Condvar::new(),
));
let polling = shared.clone();
thread::spawn(move || {
let mut system_bus = StatusBus::new(true);
let mut session_bus = StatusBus::new(false);
let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager");
let mut bluetooth = dbus_cache::Cache::new("org.bluez");
loop {
// A poll overlapping a write is discarded below. Wait for the
// write to finish before retrying instead of launching commands
@@ -159,9 +169,11 @@ impl Service {
snapshot.network = network.read(before, network::read);
snapshot.bluetooth = bluetooth.read(before, bluetooth::read);
let state = polling.0.lock().unwrap();
if state.3 {
break;
}
if state.0 == before && !state.1 {
updates.send_replace(Some(Update {
snapshot,
@@ -172,6 +184,7 @@ impl Service {
}
}
});
thread::spawn(move || {
loop {
let first = match commands.recv() {
@@ -200,6 +213,7 @@ impl Service {
shared.0.lock().unwrap().3 = true;
shared.1.notify_one();
});
Self {
settings: live_settings,
tx,
@@ -214,6 +228,7 @@ impl Service {
.try_send((generation, action))
.map_err(|_| "Controls are busy; please try again".to_owned())?;
self.generation = generation;
Ok(())
}
@@ -228,13 +243,16 @@ impl Service {
pub fn available(program: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let executable = |p: &Path| {
p.metadata()
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
};
if program.contains('/') {
return executable(Path::new(program));
}
env::var_os("PATH")
.is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
}
@@ -243,6 +261,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
if !available(program) {
return Err(format!("{program} is not installed"));
}
// Coreutils timeout bounds disconnected D-Bus services, too. Never invoke a shell.
let output = Command::new("timeout")
.args(["--kill-after=1s", "2s", program])
@@ -251,6 +270,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
.stdin(Stdio::null())
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr)
.trim()
@@ -263,6 +283,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
message
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
@@ -291,12 +312,14 @@ impl StatusBus {
} else {
zbus::blocking::connection::Builder::session()
};
self.connection = builder
.ok()?
.method_timeout(Duration::from_secs(2))
.build()
.ok();
}
match query(self.connection.as_ref()?) {
Ok(value) => Some(value),
Err(_) => {
@@ -346,6 +369,7 @@ fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot {
bluetooth: None,
audio: audio(),
battery: battery(),
power_profiles: power_profiles(system_bus),
brightness: brightness(),
notifications: notifications(session_bus),
poweroff: system_bus.can_power("CanPowerOff"),
@@ -361,9 +385,11 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
.next()?
.parse::<f32>()
.ok()?;
if !volume.is_finite() || volume < 0.0 {
return None;
}
Some((
(volume * 100.0).round().clamp(0.0, 100.0) as u8,
value.contains("[MUTED]"),
@@ -379,6 +405,7 @@ fn audio() -> Option<Audio> {
.find_map(|line| line.trim().strip_prefix("node.description = "))
.map(|s| s.trim_matches('"').to_owned())
.unwrap_or_else(|| "Default output".into());
Some(Audio {
volume,
muted,
@@ -403,6 +430,7 @@ fn battery() -> Option<Battery> {
&& read(p, "scope").as_deref() != Some("Device")
})
.collect::<Vec<_>>();
batteries.sort();
batteries.iter().find_map(|path| {
Some(Battery {
@@ -412,6 +440,46 @@ fn battery() -> Option<Battery> {
})
}
fn power_profiles(bus: &mut StatusBus) -> Option<PowerProfiles> {
bus.query(|connection| {
read_power_profiles(
connection,
"org.freedesktop.UPower.PowerProfiles",
"/org/freedesktop/UPower/PowerProfiles",
)
.or_else(|_| {
read_power_profiles(
connection,
"net.hadess.PowerProfiles",
"/net/hadess/PowerProfiles",
)
})
})
}
fn read_power_profiles(
connection: &zbus::blocking::Connection,
destination: &str,
path: &str,
) -> zbus::Result<PowerProfiles> {
let proxy = zbus::blocking::Proxy::new(connection, destination, path, destination)?;
let active = proxy.get_property::<String>("ActiveProfile")?;
let profiles = proxy
.get_property::<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>(
"Profiles",
)?;
let names = ["power-saver", "balanced", "performance"];
let available = names.map(|name| {
profiles.iter().any(|profile| {
profile
.get("Profile")
.and_then(|value| String::try_from(value.clone()).ok())
.is_some_and(|value| value == name)
})
});
Ok(PowerProfiles { active, available })
}
fn brightness() -> Option<u8> {
parse_brightness(
&run(
@@ -490,6 +558,12 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
&[if *on { "--dnd-on" } else { "--dnd-off" }],
),
Action::Notifications => run("swaync-client", &["--open-panel"]),
Action::PowerProfile(profile)
if matches!(*profile, "power-saver" | "balanced" | "performance") =>
{
run("powerprofilesctl", &["set", profile])
}
Action::PowerProfile(_) => return Err("Invalid power profile".to_owned()),
Action::Poweroff => run("systemctl", &["poweroff"]),
Action::Reboot => run("systemctl", &["reboot"]),
Action::Suspend => run("systemctl", &["suspend"]),
@@ -523,8 +597,10 @@ mod tests {
pub(super) struct TestBus {
daemon: std::process::Child,
pub(super) address: String,
}
impl TestBus {
pub(super) fn new() -> Self {
use std::io::{BufRead, BufReader};
@@ -534,14 +610,17 @@ mod tests {
.spawn()
.unwrap();
let mut address = String::new();
BufReader::new(daemon.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
Self {
daemon,
address: address.trim().to_owned(),
}
}
pub(super) fn connect(&self) -> zbus::blocking::Connection {
zbus::blocking::connection::Builder::address(self.address.as_str())
.unwrap()
@@ -550,6 +629,7 @@ mod tests {
.unwrap()
}
}
impl Drop for TestBus {
fn drop(&mut self) {
let _ = self.daemon.kill();
@@ -557,6 +637,20 @@ mod tests {
}
}
#[test]
#[ignore = "requires a running Power Profiles D-Bus service"]
fn reads_available_power_profiles_from_system_bus() {
let mut bus = StatusBus::new(true);
let profiles = power_profiles(&mut bus).expect("Power Profiles service is available");
let names = ["power-saver", "balanced", "performance"];
assert!(
names
.iter()
.zip(profiles.available)
.any(|(name, available)| { available && profiles.active == *name })
);
}
struct MockLogin1(Arc<std::sync::atomic::AtomicU8>);
#[zbus::interface(name = "org.freedesktop.login1.Manager")]
+255 -5
View File
@@ -16,6 +16,7 @@ pub enum Menu {
Bluetooth,
Audio,
Battery,
Calendar,
Recording,
Notifications,
System,
@@ -25,7 +26,8 @@ impl Menu {
fn width(self) -> f32 {
match self {
Self::System => 360.0,
Self::Battery => 280.0,
Self::Battery => 328.0,
Self::Calendar => 310.0,
Self::Notifications => 368.0,
_ => 300.0,
}
@@ -42,6 +44,7 @@ impl Menu {
Self::Bluetooth => "Bluetooth",
Self::Audio => "Sound",
Self::Battery => "Battery",
Self::Calendar => "Calendar",
Self::Recording => "Screen recording",
Self::Notifications => "Notifications",
}
@@ -53,8 +56,9 @@ impl Menu {
Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(),
Self::Battery => status.battery.is_some(),
Self::Calendar | Self::Recording => true,
Self::Notifications => status.notifications.is_some(),
Self::System | Self::Recording => true,
Self::System => true,
}
}
}
@@ -89,6 +93,9 @@ impl FereseShell {
}
return self.close_menu();
}
if kind == Menu::Calendar {
self.calendar_offset = 0;
}
let destroy = self.destroy_menu();
if !kind.available(&self.status) {
return destroy;
@@ -451,6 +458,8 @@ impl FereseShell {
]
.spacing(8),
);
} else if kind == Menu::Calendar {
rows = rows.push(calendar_grid(self.calendar_offset, theme, p));
} else {
let combined = menu.kind == Menu::System;
if combined {
@@ -717,6 +726,80 @@ impl FereseShell {
);
}
}
if menu.kind == Menu::Battery {
rows = rows.push(text("Power mode").size(13).class(theme::Text::Color(muted)));
if let Some(profiles) = &self.status.power_profiles {
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
for (index, (label, icon, profile)) in [
(
"Battery saver",
include_bytes!("../assets/icons/status/power-saver.svg").as_slice(),
"power-saver",
),
(
"Balanced",
include_bytes!("../assets/icons/status/power-balanced.svg").as_slice(),
"balanced",
),
(
"Performance",
include_bytes!("../assets/icons/status/power-performance.svg")
.as_slice(),
"performance",
),
]
.into_iter()
.enumerate()
{
if profiles.available[index] {
modes = modes.push(power_tile(
label,
icon,
profile,
profiles.active == profile,
theme,
p,
));
}
}
rows = rows.push(modes);
} else {
rows = rows.push(
text("Power Profiles service unavailable")
.size(12)
.class(theme::Text::Color(muted)),
);
}
let mut power = row::with_capacity(3).spacing(6).width(Length::Fill);
for (enabled, label, icon, action) in [
(
self.status.reboot,
"Restart",
include_bytes!("../assets/icons/status/restart.svg").as_slice(),
Action::Reboot,
),
(
self.status.poweroff,
"Power off",
include_bytes!("../assets/icons/status/power-off.svg").as_slice(),
Action::Poweroff,
),
(
self.status.suspend,
"Suspend",
include_bytes!("../assets/icons/status/suspend.svg").as_slice(),
Action::Suspend,
),
] {
if enabled {
power = power.push(power_tile_action(label, icon, action, theme, p));
}
}
if self.status.reboot || self.status.poweroff || self.status.suspend {
rows = rows.push(text("Power").size(13).class(theme::Text::Color(muted)));
rows = rows.push(power);
}
}
if combined {
let mut actions = column::with_capacity(2).spacing(8);
let mut power = row::with_capacity(3).spacing(6).width(Length::Fill);
@@ -1002,6 +1085,155 @@ fn connection_control<'a>(
.into()
}
fn power_tile<'a>(
label: &'static str,
icon: &'static [u8],
profile: &'static str,
selected: bool,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(
if selected {
theme.accent
} else {
theme.text_primary
},
opacity,
);
let tile = column![bar_icon(icon, 19, foreground), text(label).size(11)]
.spacing(3)
.align_x(Alignment::Center);
motion::button(
button::custom(tile)
.width(Length::FillPortion(1))
.padding([8, 2])
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(
profile,
)))),
foreground,
selected,
opacity,
)
}
fn power_tile_action<'a>(
label: &'static str,
icon: &'static [u8],
action: Action,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(theme.text_primary, opacity);
motion::button(
button::custom(
column![bar_icon(icon, 19, foreground), text(label).size(11)]
.spacing(3)
.align_x(Alignment::Center),
)
.width(Length::FillPortion(1))
.padding([8, 2])
.on_press(cosmic::Action::App(Message::ConfirmPower(action))),
foreground,
false,
opacity,
)
}
fn calendar_month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize, usize) {
let month_index = i32::from(today.year()) * 12 + i32::from(today.month()) - 1 + offset;
let year = month_index.div_euclid(12) as i16;
let month = (month_index.rem_euclid(12) + 1) as i8;
let first = jiff::civil::Date::new(year, month, 1).expect("calendar month is in range");
let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Monday)).unwrap_or(0);
let days = usize::try_from(first.days_in_month()).unwrap_or(31);
(first, start, days)
}
fn calendar_grid<'a>(
offset: i32,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let today = Zoned::now().date();
let months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
let (first, start, days) = calendar_month(today, offset);
let year = first.year();
let month = first.month();
let primary = color_with_opacity(theme.text_primary, opacity);
let muted = color_with_opacity(theme.text_muted, opacity);
let header = row![
text(format!("{} {year}", months[(month - 1) as usize]))
.size(15)
.width(Length::Fill),
menu_button("‹", Message::CalendarMonth(-1), primary, opacity),
menu_button("Today", Message::CalendarToday, primary, opacity),
menu_button("›", Message::CalendarMonth(1), primary, opacity),
]
.align_y(Alignment::Center)
.spacing(2);
let mut grid = column::with_capacity(7).spacing(2).push(header);
let mut weekdays = row::with_capacity(7).spacing(2);
for label in ["M", "T", "W", "T", "F", "S", "S"] {
weekdays = weekdays.push(
container(text(label).size(11).class(theme::Text::Color(muted)))
.width(Length::FillPortion(1))
.center_x(Length::Fill),
);
}
grid = grid.push(weekdays);
for week in 0..(start + days).div_ceil(7) {
let mut dates = row::with_capacity(7).spacing(2);
for weekday in 0..7 {
let cell = week * 7 + weekday;
let day = cell
.checked_sub(start)
.map(|day| day + 1)
.filter(|day| *day <= days);
let is_today =
year == today.year() && month == today.month() && day == Some(today.day() as usize);
let label = day.map_or(String::new(), |day| day.to_string());
dates = dates.push(
container(text(label).size(12).class(theme::Text::Color(if is_today {
primary
} else {
muted
})))
.width(Length::FillPortion(1))
.height(27)
.center_x(Length::Fill)
.center_y(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: is_today.then_some(Background::Color(color_with_opacity(
theme.accent,
0.2 * opacity,
))),
border: Border {
radius: motion::radius(12.0).into(),
..Default::default()
},
..Default::default()
})),
);
}
grid = grid.push(dates);
}
grid.into()
}
fn menu_button<'a>(
label: &'a str,
message: Message,
@@ -1146,11 +1378,12 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
Some(n) => format!("Notifications: {} unread", n.count),
_ => "Notifications: unavailable".into(),
},
Menu::Calendar => "Calendar".into(),
Menu::Recording => "Screen recording".into(),
Menu::Battery => s.battery.as_ref().map_or_else(
|| "Battery: unavailable".into(),
|b| format!("Battery: {}%, {}", b.percent, b.status),
),
Menu::Recording => "Screen recording".into(),
Menu::System => "Control Center".into(),
}
}
@@ -1203,6 +1436,8 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
Menu::Audio => s.audio.as_ref().map_or((audio_icon(0, true), false), |a| {
(audio_icon(a.volume, a.muted), !a.muted && a.volume > 0)
}),
Menu::Calendar => (include_bytes!("../assets/icons/status/calendar.svg"), true),
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
Menu::Battery => {
let Some(b) = &s.battery else {
return (
@@ -1238,7 +1473,6 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
)
}
}
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
Menu::System => (
include_bytes!("../assets/icons/status/control-center.svg"),
true,
@@ -1248,12 +1482,28 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
#[cfg(test)]
mod tests {
#[test]
fn calendar_aligns_leap_months_and_year_boundaries() {
let february = jiff::civil::Date::new(2024, 2, 15).unwrap();
let (first, start, days) = calendar_month(february, 0);
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29));
let (first, start, days) = calendar_month(february, -2);
assert_eq!(
(first.year(), first.month(), start, days),
(2023, 12, 4, 31)
);
let june = jiff::civil::Date::new(2025, 6, 1).unwrap();
let (_, start, days) = calendar_month(june, 0);
assert_eq!((start, days), (6, 30));
}
#[test]
fn smaller_popups_have_content_specific_widths() {
assert_eq!(Menu::System.width(), 360.0);
assert_eq!(Menu::Network.width(), 300.0);
assert_eq!(Menu::Bluetooth.width(), 300.0);
assert_eq!(Menu::Battery.width(), 280.0);
assert_eq!(Menu::Battery.width(), 328.0);
assert_eq!(Menu::Calendar.width(), 310.0);
assert_eq!(Menu::Audio.width(), 300.0);
assert_eq!(Menu::Notifications.width(), 368.0);
}