Fix compositor output routing and pointer hints; format imports

This commit is contained in:
Samuel Onoja committed 2026-09-30 06:39:58 +01:00
1 parent 88e4f3c135
commit 46509f880a
108 files changed
+3682 -8371

No files matched your search

+22 -41
View File
@@ -1,9 +1,8 @@
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use std::{
os::unix::net::UnixStream,
path::PathBuf,
time::{Duration, Instant},
};
pub(super) fn call(command: &str) -> Result<Value, String> {
request(command, json!({}))
@@ -33,10 +32,7 @@ impl SessionConnection {
}
fn request(&self, command: &str, args: Value) -> Result<Value, String> {
let mut stream = self
.stream
.lock()
.map_err(|_| "Compositor connection failed")?;
let mut stream = self.stream.lock().map_err(|_| "Compositor connection failed")?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
@@ -45,8 +41,7 @@ impl SessionConnection {
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response = ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
@@ -89,24 +84,21 @@ impl Approval {
}
pub fn prepare(&self) -> Result<(), String> {
if matches!(self.mode, Mode::Shutdown | Mode::Suspend)
&& login_inhibitors(self.mode)? != self.external
{
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
if matches!(self.mode, Mode::Shutdown | Mode::Suspend) && login_inhibitors(self.mode)? != self.external {
return Err("Applications changed their inhibitors; review the confirmation again".into());
}
if self.mode == Mode::Shutdown {
self.connection.as_ref().ok_or("Missing session-ending lease")?.request(
"validate-session-end",
json!({"token": self.token, "inhibitor-revision": self.revision, "force": self.force()}),
)?;
self.connection
.as_ref()
.ok_or("Missing session-ending lease")?
.request(
"validate-session-end",
json!({"token": self.token, "inhibitor-revision": self.revision, "force": self.force()}),
)?;
} else if self.mode == Mode::Suspend {
let state = call("get-session-state")?;
if state["inhibitor-revision"].as_u64() != Some(u64::from(self.revision)) {
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
return Err("Applications changed their inhibitors; review the confirmation again".into());
}
}
Ok(())
@@ -129,19 +121,12 @@ fn login_inhibitors(mode: Mode) -> Result<Vec<String>, String> {
"org.freedesktop.login1.Manager",
)
.map_err(|error| error.to_string())?;
let inhibitors: Vec<(String, String, String, String, u32, u32)> = proxy
.call("ListInhibitors", &())
.map_err(|error| error.to_string())?;
let what = if mode == Mode::Suspend {
"sleep"
} else {
"shutdown"
};
let inhibitors: Vec<(String, String, String, String, u32, u32)> =
proxy.call("ListInhibitors", &()).map_err(|error| error.to_string())?;
let what = if mode == Mode::Suspend { "sleep" } else { "shutdown" };
let mut reasons = inhibitors
.into_iter()
.filter(|(flags, _, _, inhibition, _, _)| {
inhibition == "block" && flags.split(':').any(|flag| flag == what)
})
.filter(|(flags, _, _, inhibition, _, _)| inhibition == "block" && flags.split(':').any(|flag| flag == what))
.map(|(_, app, reason, _, _, _)| format!("{app}: {reason}"))
.collect::<Vec<_>>();
reasons.sort();
@@ -181,9 +166,7 @@ pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
}
}
let flags = if mode == Mode::Suspend { 4 } else { 1 };
let entries = state["inhibitors"]
.as_array()
.ok_or("Invalid inhibitor list")?;
let entries = state["inhibitors"].as_array().ok_or("Invalid inhibitor list")?;
let mut reasons = entries
.iter()
.filter(|entry| entry["flags"].as_u64().unwrap_or(0) & flags != 0)
@@ -192,9 +175,7 @@ pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
.as_str()
.filter(|app| !app.is_empty())
.unwrap_or("Application");
let reason = entry["reason"]
.as_str()
.unwrap_or("Requested session inhibition");
let reason = entry["reason"].as_str().unwrap_or("Requested session inhibition");
format!("{app}: {reason}")
})
.collect::<Vec<_>>();
+11 -34
View File
@@ -93,8 +93,7 @@ impl Default for ShellTheme {
impl ShellTheme {
pub(crate) fn palette(self) -> ferese_theme::Palette {
let color =
|[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let surface = color(self.surface_base);
ferese_theme::Palette {
background: surface,
@@ -330,14 +329,8 @@ pub(crate) fn load() -> ShellConfig {
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
match ferese_config::from_str::<FereseConfig>(source) {
Ok(config) => {
config
.notifications
.validate()
.map_err(ferese_config::Error::from)?;
config
.desktop_widgets
.validate()
.map_err(ferese_config::Error::from)?;
config.notifications.validate().map_err(ferese_config::Error::from)?;
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
let mut theme = shell_theme(&config.theme);
theme.material_radius = nonnegative_or(
config
@@ -387,19 +380,15 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
ShellTheme {
material_radius: defaults.material_radius,
bar_background: {
let mut background =
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
background[3] = alpha;
background
},
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
.unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted)
.unwrap_or(defaults.bar_text_muted),
bar_text_primary: parse_color(&theme.surface.bar.text_primary).unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted).unwrap_or(defaults.bar_text_muted),
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
surface_popover: {
let mut color =
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = alpha;
color
},
@@ -424,8 +413,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap),
shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y),
shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur),
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity)
.clamp(0.0, 1.0),
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity).clamp(0.0, 1.0),
}
}
@@ -556,16 +544,8 @@ mod tests {
.clock;
assert!(clock.enabled);
assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight);
assert!(
super::parse_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n")
.is_err()
);
assert!(
super::parse_source(
"desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n"
)
.is_err()
);
assert!(super::parse_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err());
assert!(super::parse_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err());
}
use super::*;
@@ -603,10 +583,7 @@ theme {
config.theme.typography.font_family.as_deref(),
Some("JetBrainsMono Nerd Font")
);
assert_eq!(
config.theme.background.path,
Some(PathBuf::from("/tmp/wallpaper.png"))
);
assert_eq!(config.theme.background.path, Some(PathBuf::from("/tmp/wallpaper.png")));
assert_eq!(config.theme.background.mode, WallpaperMode::Fit);
}
+20 -42
View File
@@ -1,22 +1,16 @@
use std::{
error::Error,
os::{
fd::{FromRawFd, RawFd},
unix::net::UnixStream,
},
sync::mpsc::{self, Receiver, Sender, TryRecvError},
thread,
};
use std::error::Error;
use std::os::fd::{FromRawFd, RawFd};
use std::os::unix::net::UnixStream;
use std::sync::mpsc;
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::thread;
use ferese_protocols::shell::v1::client::{
ferese_shell_manager_v1::FereseShellManagerV1,
ferese_shell_v1::{self, FereseShellV1},
};
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop,
globals::{GlobalListContents, registry_queue_init},
protocol::wl_registry,
};
use ferese_protocols::shell::v1::client::ferese_shell_manager_v1::FereseShellManagerV1;
use ferese_protocols::shell::v1::client::ferese_shell_v1;
use ferese_protocols::shell::v1::client::ferese_shell_v1::FereseShellV1;
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop};
#[derive(Clone, Debug, Default)]
pub(crate) struct ShellSnapshot {
@@ -116,11 +110,7 @@ impl ShellControl {
match self.updates.try_recv() {
Ok(ControlUpdate::Logout(serial, output)) => poll.logout = Some((serial, output)),
Ok(ControlUpdate::LogoutCancelled(serial)) => {
if poll
.logout
.as_ref()
.is_some_and(|(pending, _)| *pending == serial)
{
if poll.logout.as_ref().is_some_and(|(pending, _)| *pending == serial) {
poll.logout = None;
}
poll.logout_cancelled.push(serial);
@@ -174,8 +164,8 @@ impl ShellControl {
}
fn control_connection() -> Result<Connection, Box<dyn Error>> {
let raw_fd = std::env::var_os("FERESE_SHELL_CONTROL_SOCKET")
.ok_or("Ferese did not provide a shell-control connection")?;
let raw_fd =
std::env::var_os("FERESE_SHELL_CONTROL_SOCKET").ok_or("Ferese did not provide a shell-control connection")?;
let fd = raw_fd
.to_str()
.ok_or("FERESE_SHELL_CONTROL_SOCKET is not valid UTF-8")?
@@ -299,26 +289,14 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
fullscreen,
});
}
ferese_shell_v1::Event::SnapshotEnd { serial }
if state.serial.take() == Some(serial) =>
{
ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => {
state.pending.workspaces.sort_unstable_by_key(|workspace| {
ferese_core::workspace_order_key(
&workspace.name,
ferese_core::WorkspaceId(workspace.id),
)
ferese_core::workspace_order_key(&workspace.name, ferese_core::WorkspaceId(workspace.id))
});
let _ = state
.sender
.send(ControlUpdate::Snapshot(state.pending.clone()));
let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone()));
}
ferese_shell_v1::Event::LogoutRequested {
serial,
output_name,
} => {
let _ = state
.sender
.send(ControlUpdate::Logout(serial, output_name));
ferese_shell_v1::Event::LogoutRequested { serial, output_name } => {
let _ = state.sender.send(ControlUpdate::Logout(serial, output_name));
}
ferese_shell_v1::Event::LogoutCancelled { serial } => {
let _ = state.sender.send(ControlUpdate::LogoutCancelled(serial));
+1 -2
View File
@@ -6,8 +6,7 @@ pub(super) struct Entry {
pub(super) fn load() -> Result<Vec<Entry>, String> {
let entries: Vec<Entry> =
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?)
.map_err(|error| error.to_string())?;
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?).map_err(|error| error.to_string())?;
if entries.len() > 256
|| entries
.iter()
File diff suppressed because it is too large. Load diff
+36 -108
View File
@@ -1,6 +1,5 @@
//! Draw-only popup transform; layout stays stable while its presentation animates.
use cosmic::iced::advanced::Renderer as _;
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
@@ -42,8 +41,7 @@ impl Settings {
}
}
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
static SHELL_RADIUS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(14.0_f32.to_bits());
static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits());
/// All shell shapes share one radius, capped to fit smaller controls.
pub(crate) fn radius(limit: f32) -> f32 {
@@ -97,17 +95,14 @@ impl PopupMotion {
}
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
let duration = self
.settings
.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
let duration = self.settings.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
if duration.is_zero() {
return self.target;
}
let Some(started) = self.started else {
return self.start;
};
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32())
.min(1.0);
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0);
if t == 1.0 {
return self.target;
}
@@ -145,14 +140,7 @@ pub(crate) fn button<'a, M: Clone + 'a>(
let active = progress.clone();
let hovered = progress.clone();
let paint = move |progress: f32, pressed: bool| {
ferese_theme::controls::shell_button(
foreground,
selected,
opacity,
radius(14.),
progress,
pressed,
)
ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed)
};
let content = button
.class(cosmic::theme::Button::Custom {
@@ -181,12 +169,7 @@ struct HoverState {
}
impl HoverState {
fn advance(
&mut self,
target: f32,
now: std::time::Instant,
duration: std::time::Duration,
) -> bool {
fn advance(&mut self, target: f32, now: std::time::Instant, duration: std::time::Duration) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
@@ -196,8 +179,7 @@ impl HoverState {
1.0
} else {
self.started.map_or(1.0, |start| {
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
.min(1.0)
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32()).min(1.0)
})
};
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
@@ -222,9 +204,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
}
fn tag(&self) -> widget::tree::Tag {
@@ -298,17 +278,10 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.progress
.set(tree.state.downcast_ref::<HoverState>().current);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
self.progress.set(tree.state.downcast_ref::<HoverState>().current);
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
}
fn mouse_interaction(
@@ -319,13 +292,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn operate(
@@ -348,22 +317,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
pub fn animated<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
regions: Regions,
radius: f32,
) -> Element<'a, M> {
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
Element::new(Motion {
content,
progress,
@@ -445,9 +405,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
}
*self.regions.lock().unwrap() = collector.into_regions(self.translation(), self.radius);
let cursor = self.cursor(cursor);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
self.content
.as_widget_mut()
.update(tree, event, layout, cursor, renderer, clipboard, shell, viewport);
}
fn draw(
@@ -463,15 +423,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
let v = self.translation();
let transform = Transformation::translate(v.x, v.y);
renderer.with_transformation(transform, |renderer| {
self.content.as_widget().draw(
tree,
renderer,
theme,
style,
layout,
self.cursor(cursor),
viewport,
)
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, self.cursor(cursor), viewport)
});
}
@@ -483,13 +437,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
tree,
layout,
self.cursor(cursor),
viewport,
renderer,
)
self.content
.as_widget()
.mouse_interaction(tree, layout, self.cursor(cursor), viewport, renderer)
}
fn operate(
@@ -499,9 +449,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
self.content.as_widget_mut().operate(tree, layout, renderer, operation);
}
}
@@ -557,11 +505,7 @@ mod tests {
fn centered_modal_material_tracks_surface_position_and_animation() {
let mut collector = CollectRegions(Vec::new());
let card = Rectangle::new((380.0, 420.0).into(), (440.0, 220.0).into());
widget::Operation::container(
&mut collector,
Some(&widget::Id::new("ferese-blur-card")),
card,
);
widget::Operation::container(&mut collector, Some(&widget::Id::new("ferese-blur-card")), card);
assert_eq!(
collector.into_regions(Vector::new(0.0, -2.0), 14.0),
vec![[380, 418, 440, 220, 14]]
@@ -572,29 +516,16 @@ mod tests {
fn popup_waits_for_configuration_then_opens_and_closes_faster() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
assert_eq!(
motion.progress_at(now + std::time::Duration::from_secs(30)),
0.0
);
assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0);
motion.begin(now);
// Later configure/focus notifications must not restart the clock.
motion.begin(now + std::time::Duration::from_millis(90));
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(200)),
1.0
);
assert!((motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(200)), 1.0);
motion.retarget(0.0, now + std::time::Duration::from_millis(200));
assert!(motion.closing());
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(340)),
0.0
);
assert!((motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(340)), 0.0);
}
#[test]
@@ -613,10 +544,7 @@ mod tests {
let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen);
assert_eq!(motion.progress_at(reopen), before);
assert_eq!(
motion.progress_at(reopen + std::time::Duration::from_secs(1)),
1.0
);
assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(1)), 1.0);
for settings in [
Settings {
reduced_motion: true,
+6 -10
View File
@@ -1,5 +1,7 @@
//! Atomic, conflict-checked note edits; always called from a worker thread.
use std::{fs, io::Write, path::Path};
use std::fs;
use std::io::Write;
use std::path::Path;
#[derive(Clone, Debug)]
pub enum Edit {
@@ -63,13 +65,10 @@ pub fn save(path: &Path, edits: &[Edit]) -> Result<(), String> {
file.as_file()
.set_permissions(fs::metadata(path).map_err(|e| e.to_string())?.permissions())
.map_err(|e| e.to_string())?;
file.write_all(updated.as_bytes())
.map_err(|e| e.to_string())?;
file.write_all(updated.as_bytes()).map_err(|e| e.to_string())?;
file.as_file().sync_all().map_err(|e| e.to_string())?;
if fs::read_to_string(path).map_err(|e| e.to_string())? != source {
return Err(
"Config changed while saving; your note remains open. Edit again to retry.".into(),
);
return Err("Config changed while saving; your note remains open. Edit again to retry.".into());
}
file.persist(path).map_err(|e| e.to_string())?;
fs::File::open(parent)
@@ -131,10 +130,7 @@ desktop-widgets {
let source = fs::read_to_string(&path).unwrap();
assert!(source.contains("// keep this"));
assert!(source.contains("#123456"));
let notes = crate::config::parse_source(&source)
.unwrap()
.desktop_widgets
.notes;
let notes = crate::config::parse_source(&source).unwrap().desktop_widgets.notes;
assert_eq!(notes[0].text, "one\ntwo");
assert_eq!((notes[0].margin_x, notes[0].margin_y), (50, 60));
assert_eq!(notes[0].anchor, ferese_core::desktop::Anchor::TopLeft);
+74 -129
View File
@@ -1,15 +1,12 @@
use super::*;
use cosmic::iced::widget::scrollable::{Direction, Scrollbar};
use cosmic::widget::{column, scrollable};
use super::*;
const POPUP_WIDTH: u32 = 368;
fn popup_height(notice: &notifications::Notice, count: usize) -> u32 {
let body = if notice.body.is_empty() && count == 1 {
0
} else {
22
};
let body = if notice.body.is_empty() && count == 1 { 0 } else { 22 };
let actions = if notice.live && notice.actions.iter().any(|(key, _)| key != "default") {
39
} else {
@@ -33,18 +30,13 @@ fn history_height(center: &notifications::Center) -> u32 {
} else {
1
};
popup_height(notice, count)
+ wrapped_lines.saturating_sub(1) as u32 * 17
+ if count > 1 { 12 } else { 0 }
popup_height(notice, count) + wrapped_lines.saturating_sub(1) as u32 * 17 + if count > 1 { 12 } else { 0 }
};
let cards: u32 = groups
.iter()
.map(|group| {
if group.len() > 1 && center.expanded_apps.contains(&group[0].app) {
32 + group
.iter()
.map(|notice| card_height(notice, 1))
.sum::<u32>()
32 + group.iter().map(|notice| card_height(notice, 1)).sum::<u32>()
+ group.len().saturating_sub(1) as u32 * 8
} else {
card_height(group[0], group.len())
@@ -188,9 +180,10 @@ impl FereseShell {
.outputs
.iter()
.find(|entry| {
self.snapshot.outputs.iter().any(|output| {
output.focused && Some(output.name.as_str()) == entry.name.as_deref()
})
self.snapshot
.outputs
.iter()
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
})
.or_else(|| self.outputs.first())
.map(|entry| entry.output.clone());
@@ -213,9 +206,7 @@ impl FereseShell {
.notifications
.popup_groups()
.iter()
.map(|(notice, _, count)| {
popup_height(notice, *count) + if *count > 1 { 12 } else { 0 }
})
.map(|(notice, _, count)| popup_height(notice, *count) + if *count > 1 { 12 } else { 0 })
.sum::<u32>()
+ count.saturating_sub(1) as u32 * 8
+ 8;
@@ -299,8 +290,7 @@ impl FereseShell {
let id = notice.id;
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
bold.weight = cosmic::iced::font::Weight::Bold;
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty()
{
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() {
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
} else if notice.icon.starts_with('/') {
icon::icon(icon::from_path(notice.icon.clone().into())).size(18)
@@ -308,18 +298,17 @@ impl FereseShell {
icon::from_name(notice.icon.as_str()).size(18).icon()
}
.opacity(opacity);
let icon_well =
container(app_icon)
.center_x(30)
.center_y(30)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(well)),
border: Border {
radius: motion::radius(15.0).into(),
..Default::default()
},
let icon_well = container(app_icon)
.center_x(30)
.center_y(30)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(well)),
border: Border {
radius: motion::radius(15.0).into(),
..Default::default()
}));
},
..Default::default()
}));
let icon: Element<'_, cosmic::Action<Message>> = if count > 1 {
let badge_fill = ferese_theme::composite(
color(palette.accent),
@@ -329,8 +318,7 @@ impl FereseShell {
palette.surface_popover
}),
);
let (badge_fill, badge_text) =
ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let badge = container(
text(count.to_string())
.size(10)
@@ -375,15 +363,10 @@ impl FereseShell {
.align_y(alignment::Vertical::Center)
.push(icon)
.push(
container(
text(app)
.size(11)
.font(bold)
.class(theme::Text::Color(muted)),
)
.width(Length::Fill)
.height(16)
.clip(true),
container(text(app).size(11).font(bold).class(theme::Text::Color(muted)))
.width(Length::Fill)
.height(16)
.clip(true),
)
.push(
text(age_label(notice.received_at.elapsed()))
@@ -446,13 +429,13 @@ impl FereseShell {
let mut actions = row([]);
for (index, (key, label)) in available.into_iter().enumerate() {
if index > 0 {
actions = actions.push(
container(cosmic::iced::widget::Space::new().width(1).height(38))
.class(theme::Container::custom(move |_| container::Style {
actions =
actions.push(container(cosmic::iced::widget::Space::new().width(1).height(38)).class(
theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(divider)),
..Default::default()
})),
);
}),
));
}
actions = actions.push(card_button(
container(
@@ -472,17 +455,12 @@ impl FereseShell {
}
face = face
.push(
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(1),
)
.class(theme::Container::custom(move |_| {
container::Style {
container(cosmic::iced::widget::Space::new().width(Length::Fill).height(1)).class(
theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(divider)),
..Default::default()
}
})),
}),
),
)
.push(actions);
}
@@ -518,23 +496,19 @@ impl FereseShell {
// bottom edges, after the card's measured height.
let back = move |inset: f32| {
container(
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(6),
)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
a: 0.045 * opacity,
..foreground
})),
border: Border {
radius: [0.0, 0.0, motion::radius(6.0), motion::radius(6.0)].into(),
container(cosmic::iced::widget::Space::new().width(Length::Fill).height(6))
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
a: 0.045 * opacity,
..foreground
})),
border: Border {
radius: [0.0, 0.0, motion::radius(6.0), motion::radius(6.0)].into(),
..Default::default()
},
..Default::default()
},
..Default::default()
})),
})),
)
.padding([0.0, inset])
};
@@ -552,10 +526,7 @@ impl FereseShell {
Message::ToggleNotificationHistory
}));
} else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") {
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(
id,
"default".into(),
)));
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(id, "default".into())));
}
area.into()
}
@@ -589,11 +560,7 @@ impl FereseShell {
} else {
format!(
"{count} {} · {} {}",
if count == 1 {
"notification"
} else {
"notifications"
},
if count == 1 { "notification" } else { "notifications" },
groups.len(),
if groups.len() == 1 { "app" } else { "apps" }
)
@@ -619,11 +586,7 @@ impl FereseShell {
well,
28,
));
let dnd_color = if self.notifications.dnd {
accent
} else {
muted
};
let dnd_color = if self.notifications.dnd { accent } else { muted };
let dnd_icon = if self.notifications.dnd {
ferese_theme::icons::NOTIFICATIONS_OFF
} else {
@@ -674,40 +637,34 @@ impl FereseShell {
self.notifications.dnd,
1.0,
));
let controls =
container(dnd)
.padding(10)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(base)),
border: Border {
radius: palette.material_radius.into(),
..Default::default()
},
let controls = container(dnd)
.padding(10)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(base)),
border: Border {
radius: palette.material_radius.into(),
..Default::default()
}));
},
..Default::default()
}));
let mut content = column([]).spacing(10).push(header).push(controls);
if count == 0 {
let empty = column([])
.spacing(10)
.align_x(alignment::Horizontal::Center)
.push(
container(accented_icon(
ferese_theme::icons::NOTIFICATIONS,
26,
muted,
accent,
))
.center_x(56)
.center_y(56)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(well)),
border: Border {
radius: motion::radius(28.0).into(),
container(accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent))
.center_x(56)
.center_y(56)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(well)),
border: Border {
radius: motion::radius(28.0).into(),
..Default::default()
},
..Default::default()
},
..Default::default()
})),
})),
)
.push(
text("You're all caught up")
@@ -720,11 +677,7 @@ impl FereseShell {
.size(11)
.class(theme::Text::Color(muted)),
);
content = content.push(
container(empty)
.center_x(Length::Fill)
.center_y(Length::Fill),
);
content = content.push(container(empty).center_x(Length::Fill).center_y(Length::Fill));
} else {
content = content.push(
row([])
@@ -786,12 +739,7 @@ impl FereseShell {
}
entries = entries.push(cards);
} else {
entries = entries.push(self.notification_card(
latest,
opacity,
true,
group.len(),
));
entries = entries.push(self.notification_card(latest, opacity, true, group.len()));
}
}
content = content.push(
@@ -806,8 +754,7 @@ impl FereseShell {
.height(history_height(&self.notifications) as f32)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: (!compositor_material)
.then_some(Background::Color(color(palette.surface_popover))),
background: (!compositor_material).then_some(Background::Color(color(palette.surface_popover))),
border: Border {
color: color_with_opacity(palette.text_muted, 0.18 * opacity),
width: 1.0,
@@ -819,9 +766,7 @@ impl FereseShell {
motion::animated(
panel,
progress,
surface
.map(|surface| surface.regions.clone())
.unwrap_or_default(),
surface.map(|surface| surface.regions.clone()).unwrap_or_default(),
palette.material_radius,
)
} else {
+26 -75
View File
@@ -1,11 +1,7 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
sync::{
Arc, Mutex,
mpsc::{self, SyncSender},
},
time::{Duration, Instant},
};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
const PATH: &str = "/org/freedesktop/Notifications";
const INTERFACE: &str = "org.freedesktop.Notifications";
@@ -97,9 +93,7 @@ impl Server {
let mut registry = self.registry.lock().unwrap();
let replacing = registry.live.contains_key(&replaces_id);
if !replacing && registry.live.len() >= HISTORY_LIMIT {
return Err(zbus::fdo::Error::LimitsExceeded(
"Too many active notifications".into(),
));
return Err(zbus::fdo::Error::LimitsExceeded("Too many active notifications".into()));
}
let id = if replacing {
replaces_id
@@ -201,10 +195,7 @@ impl Service {
Self::connect(config, None)
}
fn connect(
config: &ferese_core::notifications::NotificationConfig,
address: Option<String>,
) -> Self {
fn connect(config: &ferese_core::notifications::NotificationConfig, address: Option<String>) -> Self {
let timeout = Arc::new(std::sync::atomic::AtomicU32::new(config.timeout_ms));
let worker_timeout = timeout.clone();
let (sender, events) = tokio::sync::mpsc::channel(256);
@@ -237,25 +228,13 @@ impl Service {
if !registry.lock().unwrap().close(id, revision, reason) {
continue;
}
connection.emit_signal(
None::<&str>,
PATH,
INTERFACE,
"NotificationClosed",
&(id, reason),
)
connection.emit_signal(None::<&str>, PATH, INTERFACE, "NotificationClosed", &(id, reason))
}
Command::Action(id, revision, action) => {
if registry.lock().unwrap().live.get(&id) != Some(&revision) {
continue;
}
connection.emit_signal(
None::<&str>,
PATH,
INTERFACE,
"ActionInvoked",
&(id, action),
)
connection.emit_signal(None::<&str>, PATH, INTERFACE, "ActionInvoked", &(id, action))
}
};
if let Err(error) = result {
@@ -337,9 +316,7 @@ impl Center {
} else {
toast.closing.map_or_else(
|| (toast.born.elapsed().as_secs_f32() / duration.as_secs_f32()).min(1.0),
|closing| {
(1.0 - closing.elapsed().as_secs_f32() / duration.as_secs_f32()).max(0.0)
},
|closing| (1.0 - closing.elapsed().as_secs_f32() / duration.as_secs_f32()).max(0.0),
)
};
Some((notice, p * p * (3.0 - 2.0 * p)))
@@ -347,18 +324,15 @@ impl Center {
}
pub fn animating(&self) -> bool {
self.toasts.iter().any(|toast| {
toast.closing.is_some() || toast.born.elapsed() < super::motion::notification_duration()
})
self.toasts
.iter()
.any(|toast| toast.closing.is_some() || toast.born.elapsed() < super::motion::notification_duration())
}
pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> {
let mut groups: Vec<(&Notice, f32, usize)> = Vec::new();
for (notice, opacity) in self.visible() {
if let Some(group) = groups
.iter_mut()
.find(|(head, _, _)| head.app == notice.app)
{
if let Some(group) = groups.iter_mut().find(|(head, _, _)| head.app == notice.app) {
group.2 += 1;
} else {
groups.push((notice, opacity, 1));
@@ -451,10 +425,7 @@ impl Center {
}
pub fn invoke(&mut self, id: u32, action: String) {
if let Some(notice) = self
.entries
.iter()
.find(|notice| notice.id == id && notice.live)
if let Some(notice) = self.entries.iter().find(|notice| notice.id == id && notice.live)
&& notice.actions.iter().any(|(key, _)| key == &action)
{
let resident = notice.resident;
@@ -593,9 +564,9 @@ impl Center {
self.close(id, 1);
}
self.toasts.retain(|toast| {
toast.closing.is_none_or(|closing| {
now.duration_since(closing) < super::motion::notification_duration()
})
toast
.closing
.is_none_or(|closing| now.duration_since(closing) < super::motion::notification_duration())
});
let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect();
self.entries
@@ -605,9 +576,10 @@ impl Center {
#[cfg(test)]
mod tests {
use super::*;
use ferese_core::notifications::NotificationConfig;
use super::*;
fn fixture() -> (Center, std::sync::mpsc::Receiver<Command>) {
let (_, events) = tokio::sync::mpsc::channel(256);
let (commands, receiver) = mpsc::sync_channel(256);
@@ -736,10 +708,7 @@ mod tests {
center.last_tick = Instant::now() - Duration::from_secs(10);
center.tick();
assert!(!center.entries[0].live);
assert!(matches!(
commands.try_recv().unwrap(),
Command::Close(1, 1, 1)
));
assert!(matches!(commands.try_recv().unwrap(), Command::Close(1, 1, 1)));
}
#[test]
@@ -786,10 +755,7 @@ mod tests {
center.invoke(1, "invalid".into());
assert!(commands.try_recv().is_err());
center.invoke(1, "open".into());
assert!(matches!(
commands.try_recv().unwrap(),
Command::Action(1, 1, _)
));
assert!(matches!(commands.try_recv().unwrap(), Command::Action(1, 1, _)));
assert!(center.entries[0].live);
}
@@ -811,13 +777,7 @@ mod tests {
..Default::default()
});
assert!(center.dnd);
assert_eq!(
center
.service
.timeout
.load(std::sync::atomic::Ordering::Relaxed),
10000
);
assert_eq!(center.service.timeout.load(std::sync::atomic::Ordering::Relaxed), 10000);
center.receive(notice(1));
center.configure(NotificationConfig {
show_popups: false,
@@ -870,8 +830,7 @@ mod tests {
.build()
.unwrap();
let proxy = zbus::blocking::Proxy::new(&connection, INTERFACE, PATH, INTERFACE).unwrap();
let info: (String, String, String, String) =
proxy.call("GetServerInformation", &()).unwrap();
let info: (String, String, String, String) = proxy.call("GetServerInformation", &()).unwrap();
assert_eq!(info.0, "Ferese");
let notify = |replace| -> u32 {
proxy
@@ -905,22 +864,14 @@ mod tests {
let (sender, receiver) = mpsc::channel();
let worker = std::thread::spawn(move || {
let signal = signals.next().unwrap();
sender
.send(signal.body().deserialize::<(u32, u32)>().unwrap())
.unwrap();
sender.send(signal.body().deserialize::<(u32, u32)>().unwrap()).unwrap();
});
proxy.call::<_, _, ()>("CloseNotification", &(id,)).unwrap();
let Event::Close(closed, revision) = received(&service) else {
panic!("Expected close");
};
service
.commands
.send(Command::Close(closed, revision, 3))
.unwrap();
assert_eq!(
receiver.recv_timeout(Duration::from_secs(3)).unwrap(),
(id, 3)
);
service.commands.send(Command::Close(closed, revision, 3)).unwrap();
assert_eq!(receiver.recv_timeout(Duration::from_secs(3)).unwrap(), (id, 3));
worker.join().unwrap();
assert!(proxy.call::<_, _, ()>("CloseNotification", &(id,)).is_err());
}
+14 -20
View File
@@ -1,13 +1,13 @@
//! The bar only controls the recorder and consumes bounded status messages.
//! Encoding and portal negotiation run in the separate ferese-record process.
use std::io::{BufRead, BufReader, Read};
use std::path::PathBuf;
use std::process::{ChildStdin, Command, Stdio};
use std::sync::mpsc;
use std::sync::mpsc::Receiver;
use std::time::Instant;
use serde::Deserialize;
use std::{
io::{BufRead, BufReader, Read},
path::PathBuf,
process::{ChildStdin, Command, Stdio},
sync::mpsc::{self, Receiver},
time::Instant,
};
#[derive(Debug, Default)]
pub enum State {
@@ -126,28 +126,22 @@ impl Recorder {
match event {
Event::Update(update) => match update.state.as_str() {
"selecting" if self.control.is_some() => self.state = State::Selecting,
"recording" if self.control.is_some() => {
self.state = State::Recording(Instant::now())
}
"recording" if self.control.is_some() => self.state = State::Recording(Instant::now()),
"saving" => self.state = State::Saving,
"saved" => {
self.state = update.path.map(State::Saved).unwrap_or_else(|| {
State::Error("Recorder returned no saved file".into())
})
self.state = update
.path
.map(State::Saved)
.unwrap_or_else(|| State::Error("Recorder returned no saved file".into()))
}
"cancelled" => self.state = State::Idle,
"error" => {
self.state = State::Error(
update.message.unwrap_or_else(|| "Recording failed".into()),
)
self.state = State::Error(update.message.unwrap_or_else(|| "Recording failed".into()))
}
_ => (),
},
Event::Exited => {
if matches!(
self.state,
State::Selecting | State::Recording(_) | State::Saving
) {
if matches!(self.state, State::Selecting | State::Recording(_) | State::Saving) {
self.state = State::Error("The recorder stopped unexpectedly".into());
}
done = true;
+31 -100
View File
@@ -3,17 +3,12 @@ mod bluetooth;
mod dbus_cache;
mod network;
use std::{
env, fs,
path::Path,
process::{Command, Stdio},
sync::{
Arc, Condvar, Mutex,
mpsc::{self, SyncSender},
},
thread,
time::Duration,
};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::time::Duration;
use std::{env, fs, thread};
#[derive(Clone, Debug, Default)]
pub struct Snapshot {
@@ -146,10 +141,7 @@ impl Service {
let (updates, rx) = tokio::sync::watch::channel(None);
// Polling and writes have separate workers: a missing D-Bus service must
// never hold up volume/brightness changes. Publish only coherent polls.
let shared = Arc::new((
Mutex::new((0u64, false, None::<String>, false)),
Condvar::new(),
));
let shared = Arc::new((Mutex::new((0u64, false, None::<String>, false)), Condvar::new()));
let polling = shared.clone();
thread::spawn(move || {
@@ -249,8 +241,7 @@ pub fn available(program: &str) -> bool {
return executable(Path::new(program));
}
env::var_os("PATH")
.is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
env::var_os("PATH").is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
}
fn run(program: &str, args: &[&str]) -> Result<String, String> {
@@ -302,10 +293,7 @@ impl StatusBus {
}
}
fn query<T>(
&mut self,
query: impl FnOnce(&zbus::blocking::Connection) -> zbus::Result<T>,
) -> Option<T> {
fn query<T>(&mut self, query: impl FnOnce(&zbus::blocking::Connection) -> zbus::Result<T>) -> Option<T> {
if self.connection.is_none() {
let builder = if self.system {
zbus::blocking::connection::Builder::system()
@@ -313,11 +301,7 @@ impl StatusBus {
zbus::blocking::connection::Builder::session()
};
self.connection = builder
.ok()?
.method_timeout(Duration::from_secs(2))
.build()
.ok();
self.connection = builder.ok()?.method_timeout(Duration::from_secs(2)).build().ok();
}
match query(self.connection.as_ref()?) {
@@ -397,8 +381,7 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
}
fn audio() -> Option<Audio> {
let (volume, muted) =
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
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()
@@ -406,17 +389,11 @@ fn audio() -> Option<Audio> {
.map(|s| s.trim_matches('"').to_owned())
.unwrap_or_else(|| "Default output".into());
Some(Audio {
volume,
muted,
output,
})
Some(Audio { volume, muted, output })
}
fn read(path: &Path, name: &str) -> Option<String> {
fs::read_to_string(path.join(name))
.ok()
.map(|s| s.trim().to_owned())
fs::read_to_string(path.join(name)).ok().map(|s| s.trim().to_owned())
}
fn battery() -> Option<Battery> {
@@ -425,10 +402,7 @@ fn battery() -> Option<Battery> {
let mut batteries = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
read(p, "type").as_deref() == Some("Battery")
&& read(p, "scope").as_deref() != Some("Device")
})
.filter(|p| read(p, "type").as_deref() == Some("Battery") && read(p, "scope").as_deref() != Some("Device"))
.collect::<Vec<_>>();
batteries.sort();
@@ -447,13 +421,7 @@ fn power_profiles(bus: &mut StatusBus) -> Option<PowerProfiles> {
"org.freedesktop.UPower.PowerProfiles",
"/org/freedesktop/UPower/PowerProfiles",
)
.or_else(|_| {
read_power_profiles(
connection,
"net.hadess.PowerProfiles",
"/net/hadess/PowerProfiles",
)
})
.or_else(|_| read_power_profiles(connection, "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles"))
})
}
@@ -464,10 +432,8 @@ fn read_power_profiles(
) -> 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 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| {
@@ -481,13 +447,7 @@ fn read_power_profiles(
}
fn brightness() -> Option<u8> {
parse_brightness(
&run(
"brightnessctl",
&["--class=backlight", "--machine-readable", "info"],
)
.ok()?,
)
parse_brightness(&run("brightnessctl", &["--class=backlight", "--machine-readable", "info"]).ok()?)
}
fn parse_brightness(value: &str) -> Option<u8> {
@@ -501,11 +461,7 @@ fn parse_brightness(value: &str) -> Option<u8> {
let current = fields.next()?.parse::<f64>().ok()?;
let _percentage = fields.next()?;
let max = fields.next()?.parse::<f64>().ok()?;
(fields.next().is_none()
&& max.is_finite()
&& max > 0.0
&& current.is_finite()
&& current >= 0.0)
(fields.next().is_none() && max.is_finite() && max > 0.0 && current.is_finite() && current >= 0.0)
.then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8)
}
@@ -554,28 +510,15 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
),
Action::Mute(on) => run(
"wpctl",
&[
"set-mute",
"@DEFAULT_AUDIO_SINK@",
if *on { "1" } else { "0" },
],
&["set-mute", "@DEFAULT_AUDIO_SINK@", if *on { "1" } else { "0" }],
),
Action::Brightness(value) => run(
"brightnessctl",
&[
"--class=backlight",
"set",
&format!("{}%", value.clamp(&1, &100)),
],
),
Action::Dnd(on) => run(
"swaync-client",
&[if *on { "--dnd-on" } else { "--dnd-off" }],
&["--class=backlight", "set", &format!("{}%", value.clamp(&1, &100))],
),
Action::Dnd(on) => run("swaync-client", &[if *on { "--dnd-on" } else { "--dnd-off" }]),
Action::Notifications => run("swaync-client", &["--open-panel"]),
Action::PowerProfile(profile)
if matches!(*profile, "power-saver" | "balanced" | "performance") =>
{
Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => {
run("powerprofilesctl", &["set", profile])
}
Action::PowerProfile(_) => return Err("Invalid power profile".to_owned()),
@@ -737,10 +680,7 @@ mod tests {
assert!(bus.can_power("CanReboot"));
assert!(!bus.can_power("CanSuspend"));
mode.store(1, Ordering::Relaxed);
assert!(
!bus.can_power("CanPowerOff"),
"capabilities must not be cached"
);
assert!(!bus.can_power("CanPowerOff"), "capabilities must not be cached");
assert!(bus.connection.is_some());
assert!(!bus.notification_service_owned());
server.request_name("org.erikreider.swaync").unwrap();
@@ -785,14 +725,11 @@ mod tests {
#[test]
fn status_stream_retains_latest_result_wakes_and_closes() {
use cosmic::iced::futures::{
Stream,
task::{ArcWake, waker},
};
use std::{
sync::atomic::{AtomicUsize, Ordering},
task::{Context, Poll},
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll};
use cosmic::iced::futures::Stream;
use cosmic::iced::futures::task::{ArcWake, waker};
#[derive(Default)]
struct WakeCount(AtomicUsize);
@@ -840,10 +777,7 @@ mod tests {
panic!("worker result missing");
};
assert_eq!(update.generation, 3);
assert!(matches!(
stream.as_mut().poll_next(&mut context),
Poll::Ready(None)
));
assert!(matches!(stream.as_mut().poll_next(&mut context), Poll::Ready(None)));
}
#[test]
@@ -864,10 +798,7 @@ mod tests {
state.1 = false;
shared.1.notify_one();
}
assert_eq!(
receiver.recv_timeout(Duration::from_secs(2)).unwrap(),
Some(8)
);
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap(), Some(8));
worker.join().unwrap();
}
+9 -24
View File
@@ -1,6 +1,8 @@
//! BlueZ status from its object graph; never starts discovery or pairing.
use zbus::blocking::Connection;
use zbus::fdo::ManagedObjects;
use super::Bluetooth;
use zbus::{blocking::Connection, fdo::ManagedObjects};
pub(super) fn read(connection: &Connection) -> zbus::Result<Option<Bluetooth>> {
let objects: ManagedObjects = connection
@@ -35,10 +37,7 @@ pub(super) fn read(connection: &Connection) -> zbus::Result<Option<Bluetooth>> {
return Ok(None);
};
let Some((path, _)) = adapters.iter().find(|(_, properties)| {
properties
.get("Address")
.and_then(|value| <&str>::try_from(value).ok())
== Some(address)
properties.get("Address").and_then(|value| <&str>::try_from(value).ok()) == Some(address)
}) else {
return Ok(None);
};
@@ -78,8 +77,9 @@ fn snapshot(objects: &ManagedObjects, selected: Option<&str>) -> Option<Bluetoot
#[cfg(test)]
mod tests {
use super::*;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Str};
use super::*;
fn graph() -> ManagedObjects {
let mut graph = ManagedObjects::new();
for (path, powered) in [("/org/bluez/hci0", true), ("/org/bluez/hci1", false)] {
@@ -93,24 +93,9 @@ mod tests {
);
}
for (path, adapter, connected, alias) in [
(
"/org/bluez/hci0/dev_a",
"/org/bluez/hci0",
true,
"Headphones",
),
(
"/org/bluez/hci0/dev_b",
"/org/bluez/hci0",
false,
"Keyboard",
),
(
"/org/bluez/hci1/dev_a",
"/org/bluez/hci1",
true,
"Other adapter",
),
("/org/bluez/hci0/dev_a", "/org/bluez/hci0", true, "Headphones"),
("/org/bluez/hci0/dev_b", "/org/bluez/hci0", false, "Keyboard"),
("/org/bluez/hci1/dev_a", "/org/bluez/hci1", true, "Other adapter"),
] {
graph.insert(
OwnedObjectPath::try_from(path).unwrap(),
+16 -41
View File
@@ -1,13 +1,14 @@
//! Keep native snapshots until the owning service signals a change.
use super::StatusBus;
use cosmic::iced::futures::{FutureExt, StreamExt, channel::oneshot};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use cosmic::iced::futures::channel::oneshot;
use cosmic::iced::futures::{FutureExt, StreamExt};
use zbus::blocking::{Connection, MessageIterator};
use super::StatusBus;
struct Signals {
revision: Arc<AtomicU64>,
alive: Arc<AtomicBool>,
@@ -44,9 +45,7 @@ impl Signals {
let (stop, stopped) = oneshot::channel::<()>();
std::thread::spawn(move || {
// Enter a runtime for zbus stream cleanup, including cancellation.
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build();
if let Ok(runtime) = runtime {
// Dropping the blocking wrappers enters zbus' runtime, so do
// this before entering our async runtime.
@@ -102,32 +101,19 @@ impl<T: Clone> Cache<T> {
}
}
pub(super) fn read(
&mut self,
action: u64,
read: fn(&Connection) -> zbus::Result<Option<T>>,
) -> Option<T> {
if self
.signals
.as_ref()
.is_some_and(|s| !s.alive.load(Ordering::Acquire))
{
pub(super) fn read(&mut self, action: u64, read: fn(&Connection) -> zbus::Result<Option<T>>) -> Option<T> {
if self.signals.as_ref().is_some_and(|s| !s.alive.load(Ordering::Acquire)) {
self.signals = None;
self.bus.connection = None;
self.value = None;
}
if self.signals.is_none() {
self.signals = self
.bus
.query(|connection| Signals::new(connection, self.service));
self.signals = self.bus.query(|connection| Signals::new(connection, self.service));
self.revision = 0;
}
// If subscription setup fails, read fresh rather than trusting a cache
// with no invalidation. Retry setup on the next regular status poll.
let revision = self
.signals
.as_ref()
.map(|s| s.revision.load(Ordering::Acquire));
let revision = self.signals.as_ref().map(|s| s.revision.load(Ordering::Acquire));
if revision.is_none()
|| revision != Some(self.revision)
|| action != self.action
@@ -149,8 +135,9 @@ impl<T: Clone> Cache<T> {
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
use super::*;
struct Counter(Arc<AtomicU64>);
#[zbus::interface(name = "org.ferese.StatusCounter")]
impl Counter {
@@ -193,13 +180,7 @@ mod tests {
assert_eq!(count.load(Ordering::Relaxed), 1);
assert_eq!(cache.read(1, read_counter), Some(2));
server
.emit_signal(
None::<&str>,
"/test",
"org.ferese.StatusCounter",
"Changed",
&(),
)
.emit_signal(None::<&str>, "/test", "org.ferese.StatusCounter", "Changed", &())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(2);
while cache.read(1, read_counter) != Some(3) {
@@ -247,13 +228,7 @@ mod tests {
};
let before = signals.revision.load(Ordering::Acquire);
server
.emit_signal(
None::<&str>,
"/test",
"org.ferese.StatusTest",
"Changed",
&(),
)
.emit_signal(None::<&str>, "/test", "org.ferese.StatusTest", "Changed", &())
.unwrap();
wait(before);
let before = signals.revision.load(Ordering::Acquire);
+6 -8
View File
@@ -1,6 +1,8 @@
//! Read NetworkManager directly; no scans or network changes are requested.
use zbus::blocking::Connection;
use zbus::zvariant::OwnedObjectPath;
use super::Network;
use zbus::{blocking::Connection, zvariant::OwnedObjectPath};
const SERVICE: &str = "org.freedesktop.NetworkManager";
const ROOT: &str = "/org/freedesktop/NetworkManager";
@@ -86,8 +88,9 @@ pub(super) fn read(connection: &Connection) -> zbus::Result<Option<Network>> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use super::*;
#[derive(Default)]
struct State {
wifi: bool,
@@ -120,12 +123,7 @@ mod tests {
impl Wireless {
#[zbus(property)]
fn active_access_point(&self) -> OwnedObjectPath {
OwnedObjectPath::try_from(if self.0.lock().unwrap().active {
"/ap"
} else {
"/"
})
.unwrap()
OwnedObjectPath::try_from(if self.0.lock().unwrap().active { "/ap" } else { "/" }).unwrap()
}
}
struct Ap(Arc<Mutex<State>>);
+48 -163
View File
@@ -1,9 +1,10 @@
use super::*;
use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner};
use cosmic::iced::{Alignment, Rectangle};
use cosmic::widget::{column, slider};
use status::{Action, Snapshot};
use super::*;
const DEVICE_LIST_HEIGHT: f32 = 180.0;
fn device_list_height(count: usize) -> Option<f32> {
@@ -131,11 +132,7 @@ impl FereseShell {
.map(|(width, _)| {
let theme = self.config.theme;
Rectangle {
x: (width
- theme.bar_margin_horizontal * 2
- theme.panel_padding.round() as i32
- 1)
.max(0),
x: (width - theme.bar_margin_horizontal * 2 - theme.panel_padding.round() as i32 - 1).max(0),
y: 0,
width: 1,
height: theme.bar_height.round() as i32,
@@ -170,8 +167,7 @@ impl FereseShell {
},
Some(Box::new(Self::view_status_menu)),
);
Task::batch([destroy, notifications])
.chain(cosmic::task::message(cosmic::Action::Surface(action)))
Task::batch([destroy, notifications]).chain(cosmic::task::message(cosmic::Action::Surface(action)))
}
pub fn destroy_menu(&mut self) -> Task<Message> {
@@ -184,9 +180,7 @@ impl FereseShell {
self.notifications.hovered = None;
}
Task::batch([
cosmic::task::message(cosmic::Action::Surface(
cosmic::surface::action::destroy_popup(menu.id),
)),
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(menu.id))),
self.sync_notification_surface(),
])
}
@@ -283,8 +277,7 @@ impl FereseShell {
status_icon(kind, &self.status)
};
let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind)
|| (kind == Menu::Recording
&& matches!(self.recorder.state, recording::State::Recording(_)));
|| (kind == Menu::Recording && matches!(self.recorder.state, recording::State::Recording(_)));
let foreground = color(if selected {
theme.accent
} else if enabled {
@@ -328,16 +321,16 @@ impl FereseShell {
.as_ref()
.is_some_and(|n| n.count > 0 && !n.dnd)
{
content = content.push(container(text("")).width(4).height(4).class(
theme::Container::custom(move |_| container::Style {
content = content.push(container(text("")).width(4).height(4).class(theme::Container::custom(
move |_| container::Style {
background: Some(Background::Color(foreground)),
border: Border {
radius: motion::radius(2.0).into(),
..Default::default()
},
..Default::default()
}),
));
},
)));
}
// A fixed button height does not center its child in libcosmic.
// center_y/center_x wrap the content in a centering layout, so use
@@ -355,10 +348,7 @@ impl FereseShell {
} else {
status_label(kind, &self.status)
})
.padding([
0.0,
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
])
.padding([0.0, ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0)])
.height(metrics.control_height)
.on_press_with_rectangle(move |offset, bounds| {
if kind == Menu::Recording {
@@ -404,11 +394,7 @@ impl FereseShell {
.into();
}
let theme = self.config.theme;
let p = if menu.effects.is_some() {
1.0
} else {
menu.progress()
};
let p = if menu.effects.is_some() { 1.0 } else { menu.progress() };
let kind = menu.kind;
let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, p);
@@ -505,10 +491,7 @@ impl FereseShell {
));
}
if self.status.network.is_some()
|| self.status.bluetooth.is_some()
|| self.status.battery.is_some()
{
if self.status.network.is_some() || self.status.bluetooth.is_some() || self.status.battery.is_some() {
rows = rows.push(control_card(connections.into(), primary, p));
}
}
@@ -690,9 +673,7 @@ impl FereseShell {
if menu.kind == Menu::Battery
&& let Some(b) = &self.status.battery
{
let battery_color = if b.percent < self.config.status.low_battery_threshold
&& b.status != "Charging"
{
let battery_color = if b.percent < self.config.status.low_battery_threshold && b.status != "Charging" {
Color {
a: p,
..Color::from_rgb8(230, 172, 90)
@@ -734,38 +715,19 @@ impl FereseShell {
}
if menu.kind == Menu::Battery {
rows = rows.push(ferese_theme::menus::section_label(
"Power mode",
shell_font(),
muted,
));
rows = rows.push(ferese_theme::menus::section_label("Power mode", shell_font(), 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 [
(
"Power saver",
ferese_theme::icons::POWER_SAVER,
"power-saver",
),
("Power saver", ferese_theme::icons::POWER_SAVER, "power-saver"),
("Balanced", ferese_theme::icons::POWER_BALANCED, "balanced"),
(
"Performance",
ferese_theme::icons::POWER_PERFORMANCE,
"performance",
),
("Performance", ferese_theme::icons::POWER_PERFORMANCE, "performance"),
]
.into_iter()
.enumerate()
{
if profiles.available[index] {
modes = modes.push(power_tile(
label,
icon,
profile,
profiles.active == profile,
theme,
p,
));
modes = modes.push(power_tile(label, icon, profile, profiles.active == profile, theme, p));
}
}
rows = rows.push(modes);
@@ -804,21 +766,13 @@ impl FereseShell {
}
if self.status.reboot || self.status.poweroff || self.status.suspend {
rows = rows.push(ferese_theme::menus::section_label(
"Power",
shell_font(),
muted,
));
rows = rows.push(ferese_theme::menus::section_label("Power", shell_font(), muted));
rows = rows.push(power);
}
}
if !menu.kind.available(&self.status) {
rows = rows.push(
text("Service unavailable")
.size(13)
.class(theme::Text::Color(muted)),
);
rows = rows.push(text("Service unavailable").size(13).class(theme::Text::Color(muted)));
}
}
@@ -835,9 +789,8 @@ impl FereseShell {
.width(kind.width())
.padding(16)
.class(theme::Container::custom(move |_| container::Style {
background: (!compositor_material).then_some(Background::Color(
color_with_opacity(theme.surface_popover, p),
)),
background: (!compositor_material)
.then_some(Background::Color(color_with_opacity(theme.surface_popover, p))),
text_color: Some(primary),
icon_color: Some(primary),
border: Border {
@@ -886,11 +839,7 @@ fn status_summary<'a>(
.center_y(32)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
a: if enabled {
0.22 * opacity
} else {
0.06 * opacity
},
a: if enabled { 0.22 * opacity } else { 0.06 * opacity },
..badge_color
})),
border: Border {
@@ -926,11 +875,7 @@ fn status_summary<'a>(
summary.into()
}
fn level_meter(
value: u8,
foreground: Color,
opacity: f32,
) -> Element<'static, cosmic::Action<Message>> {
fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element<'static, cosmic::Action<Message>> {
let segment = move |amount: u16, filled: bool| {
container(text(""))
.width(Length::FillPortion(amount))
@@ -1015,10 +960,7 @@ fn connection_control<'a>(
.height(28)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center);
let content = column![icon]
.spacing(2)
.width(Length::Fill)
.align_x(Alignment::Center);
let content = column![icon].spacing(2).width(Length::Fill).align_x(Alignment::Center);
let content = content.push(
text(connection_caption(detail))
.size(11)
@@ -1032,18 +974,12 @@ fn connection_control<'a>(
.class(theme::Text::Color(muted)),
);
let Some(action) = action else {
return container(content)
.width(Length::FillPortion(1))
.padding(4)
.into();
return container(content).width(Length::FillPortion(1)).padding(4).into();
};
let button = button::custom(content)
.width(Length::Fill)
.padding(4)
.name(format!(
"{label}: {}, {detail}",
if enabled { "on" } else { "off" }
))
.name(format!("{label}: {}, {detail}", if enabled { "on" } else { "off" }))
.on_press(cosmic::Action::App(Message::Control(action)));
let tile: Element<'_, cosmic::Action<Message>> = if selected {
button
@@ -1068,14 +1004,7 @@ fn power_tile<'a>(
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(
if selected {
theme.accent
} else {
theme.text_primary
},
opacity,
);
let foreground = color_with_opacity(if selected { theme.accent } else { theme.text_primary }, opacity);
let tile = container(
column![bar_icon(icon, 19, foreground), text(label).size(11)]
.spacing(3)
@@ -1086,9 +1015,7 @@ fn power_tile<'a>(
let btn = button::custom(tile)
.width(Length::FillPortion(1))
.padding([8, 2])
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(
profile,
))));
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(profile))));
motion::button(btn, foreground, selected, opacity)
}
@@ -1116,11 +1043,7 @@ fn power_tile_action<'a>(
motion::button(btn, foreground, false, opacity)
}
fn calendar_grid<'a>(
offset: i32,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
fn calendar_grid<'a>(offset: i32, theme: ShellTheme, opacity: f32) -> Element<'a, cosmic::Action<Message>> {
let primary = color_with_opacity(theme.text_primary, opacity);
ferese_theme::calendar::grid(
Zoned::now().date(),
@@ -1176,11 +1099,7 @@ fn toggle_row<'a>(
palette: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
ferese_theme::menus::row(
label,
shell_switch(label, on, action, palette, opacity),
shell_font(),
)
ferese_theme::menus::row(label, shell_switch(label, on, action, palette, opacity), shell_font())
}
fn slider_row(
@@ -1191,24 +1110,16 @@ fn slider_row(
icon_accent: Color,
opacity: f32,
) -> Element<'static, cosmic::Action<Message>> {
let control = slider(
if brightness { 1..=100 } else { 0..=100 },
value,
move |value| {
cosmic::Action::App(Message::Control(if brightness {
Action::Brightness(value)
} else {
Action::Volume(value)
}))
},
)
let control = slider(if brightness { 1..=100 } else { 0..=100 }, value, move |value| {
cosmic::Action::App(Message::Control(if brightness {
Action::Brightness(value)
} else {
Action::Volume(value)
}))
})
.width(Length::Fill)
.height(24)
.class(ferese_theme::menus::slider(
foreground,
opacity,
motion::radius(5.),
));
.class(ferese_theme::menus::slider(foreground, opacity, motion::radius(5.)));
row![
accented_icon(source, 18, foreground, icon_accent),
control,
@@ -1222,10 +1133,7 @@ fn slider_row(
fn status_label(kind: Menu, s: &Snapshot) -> String {
match kind {
Menu::Network => match &s.network {
Some(n) if n.enabled => format!(
"Wi-Fi: {}",
n.connection.as_deref().unwrap_or("not connected")
),
Some(n) if n.enabled => format!("Wi-Fi: {}", n.connection.as_deref().unwrap_or("not connected")),
_ => "Wi-Fi: off".into(),
},
Menu::Bluetooth => match &s.bluetooth {
@@ -1280,9 +1188,7 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
}
}
Menu::Bluetooth => match &s.bluetooth {
Some(b) if b.enabled && !b.devices.is_empty() => {
(ferese_theme::icons::BLUETOOTH_CONNECTED, true)
}
Some(b) if b.enabled && !b.devices.is_empty() => (ferese_theme::icons::BLUETOOTH_CONNECTED, true),
Some(b) if b.enabled => (ferese_theme::icons::BLUETOOTH_ON, true),
_ => (ferese_theme::icons::BLUETOOTH_OFF, false),
},
@@ -1328,14 +1234,8 @@ mod tests {
assert_eq!(super::connection_caption("88%"), "88%");
assert_eq!(super::connection_caption("Flow84@Lofree"), "Flow84@Lofr…");
assert_eq!(super::connection_caption("123456789012"), "123456789012");
assert_eq!(
super::connection_caption("My very long headphones"),
"My very lon…"
);
assert_eq!(
super::connection_caption(" Device\n name "),
"Device name"
);
assert_eq!(super::connection_caption("My very long headphones"), "My very lon…");
assert_eq!(super::connection_caption(" Device\n name "), "Device name");
let caption = super::connection_caption("耳機耳機耳機耳機耳機耳機耳機");
assert_eq!(caption.chars().count(), 12);
assert!(caption.ends_with('…'));
@@ -1397,41 +1297,26 @@ mod tests {
}),
..Default::default()
};
assert_eq!(
status_icon(Menu::Audio, &s),
(ferese_theme::icons::VOLUME_MUTE, false)
);
assert_eq!(status_icon(Menu::Audio, &s), (ferese_theme::icons::VOLUME_MUTE, false));
s.battery = Some(status::Battery {
percent: 2,
status: "Charging".into(),
});
assert_eq!(
status_icon(Menu::Battery, &s).0,
ferese_theme::icons::BATTERY_CHARGING
);
assert_eq!(status_icon(Menu::Battery, &s).0, ferese_theme::icons::BATTERY_CHARGING);
s.notifications = Some(status::Notifications {
count: 3,
dnd: true,
});
s.notifications = Some(status::Notifications { count: 3, dnd: true });
assert_eq!(
status_icon(Menu::Notifications, &s).0,
ferese_theme::icons::NOTIFICATIONS_OFF
);
assert_eq!(
status_label(Menu::Notifications, &s),
"Notifications: do not disturb"
);
assert_eq!(status_label(Menu::Notifications, &s), "Notifications: do not disturb");
s.bluetooth = Some(status::Bluetooth {
enabled: false,
devices: vec!["Headphones".into()],
});
assert_eq!(
status_icon(Menu::Bluetooth, &s).0,
ferese_theme::icons::BLUETOOTH_OFF
);
assert_eq!(status_icon(Menu::Bluetooth, &s).0, ferese_theme::icons::BLUETOOTH_OFF);
}
#[test]
+35 -95
View File
@@ -1,7 +1,8 @@
use super::*;
use cosmic::iced::widget::Space;
use cosmic::widget::column;
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PowerAction {
Poweroff,
@@ -79,26 +80,17 @@ impl SystemModal {
}
impl FereseShell {
pub(super) fn open_system_modal(
&mut self,
action: PowerAction,
output_name: Option<&str>,
) -> Task<Message> {
pub(super) fn open_system_modal(&mut self, action: PowerAction, output_name: Option<&str>) -> Task<Message> {
self.open_modal(Content::Power(action), output_name)
}
pub(super) fn rebuild_system_modal(&mut self) -> Task<Message> {
let guide = self
.system_modal
.as_ref()
.and_then(|modal| match &modal.content {
Content::Guide(entries)
if !modal.motion.closing() && self.config.status.keybinding_guide =>
{
Some(entries.clone())
}
_ => None,
});
let guide = self.system_modal.as_ref().and_then(|modal| match &modal.content {
Content::Guide(entries) if !modal.motion.closing() && self.config.status.keybinding_guide => {
Some(entries.clone())
}
_ => None,
});
let destroy = self.destroy_system_modal(true);
if let Some(entries) = guide {
if self.outputs.is_empty() {
@@ -139,11 +131,7 @@ impl FereseShell {
.iter()
.position(|output| output.name.as_deref() == Some(name))
})
.or_else(|| {
self.outputs
.iter()
.position(|output| output.bar == self.bar_surface_id)
})
.or_else(|| self.outputs.iter().position(|output| output.bar == self.bar_surface_id))
.unwrap_or(0);
let mut tasks = vec![
self.destroy_system_modal(true),
@@ -243,11 +231,7 @@ impl FereseShell {
)
}
pub(super) fn attach_power_material(
&mut self,
id: window::Id,
surface: &wl_surface::WlSurface,
) {
pub(super) fn attach_power_material(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
let Some(modal) = &mut self.system_modal else {
return;
};
@@ -268,8 +252,7 @@ impl FereseShell {
for entry in &modal.surfaces {
if let Some(effects) = &entry.effects {
let regions = entry.regions.lock().unwrap().clone();
let _ = effects
.set_material_regions(&regions, ferese_surface_effects_v1::Role::Modal);
let _ = effects.set_material_regions(&regions, ferese_surface_effects_v1::Role::Modal);
let _ = effects.set_opacity(modal.motion.progress());
}
}
@@ -353,12 +336,7 @@ impl FereseShell {
let action = match power {
PowerAction::Logout(serial) => {
if let Some(control) = &self.control {
control.confirm_logout(
serial,
approval.revision,
approval.token,
approval.force(),
);
control.confirm_logout(serial, approval.revision, approval.token, approval.force());
return self.destroy_system_modal(false);
}
modal.error = Some("Ferese shell control is unavailable".to_owned());
@@ -391,11 +369,7 @@ impl FereseShell {
Task::batch([destroy, execute])
}
pub(super) fn finish_power_action(
&mut self,
id: window::Id,
result: Result<(), String>,
) -> Task<Message> {
pub(super) fn finish_power_action(&mut self, id: window::Id, result: Result<(), String>) -> Task<Message> {
let Some((pending_id, action)) = self.pending_power else {
return Task::none();
};
@@ -454,16 +428,10 @@ impl FereseShell {
column::with_capacity(inhibitors.reasons.len()).spacing(6),
|rows, reason| rows.push(text(reason).size(13)),
);
rows = rows.push(
container(cosmic::widget::scrollable(items).height(Length::Shrink))
.max_height(160),
);
rows = rows
.push(container(cosmic::widget::scrollable(items).height(Length::Shrink)).max_height(160));
}
let label = if modal
.inhibitors
.as_ref()
.is_some_and(|approval| approval.force())
{
let label = if modal.inhibitors.as_ref().is_some_and(|approval| approval.force()) {
format!("{} anyway", action.label())
} else {
action.label().to_owned()
@@ -474,20 +442,14 @@ impl FereseShell {
rows.push(
row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button(
"Cancel",
shell_font(),
palette,
false
)
.on_press(cosmic::Action::App(Message::CancelPower)),
ferese_theme::controls::text_button(label, shell_font(), palette, true)
.on_press_maybe(
modal
.inhibitors
.is_some()
.then_some(cosmic::Action::App(Message::ExecutePower))
),
ferese_theme::controls::text_button("Cancel", shell_font(), palette, false)
.on_press(cosmic::Action::App(Message::CancelPower)),
ferese_theme::controls::text_button(label, shell_font(), palette, true).on_press_maybe(
modal
.inhibitors
.is_some()
.then_some(cosmic::Action::App(Message::ExecutePower))
),
]
.spacing(10),
)
@@ -498,9 +460,7 @@ impl FereseShell {
bindings = bindings.push(
row![
text(&entry.keys).size(13).width(Length::FillPortion(1)),
text(&entry.description)
.size(13)
.width(Length::FillPortion(1)),
text(&entry.description).size(13).width(Length::FillPortion(1)),
]
.spacing(16),
);
@@ -516,26 +476,16 @@ impl FereseShell {
.clamp(40, 400) as f32;
column![
text("Welcome to Ferese").size(23),
ferese_theme::menus::section_label(
"Your active shortcuts",
shell_font(),
palette.muted,
)
.size(14),
container(cosmic::widget::scrollable(bindings).height(Length::Shrink))
.max_height(height),
ferese_theme::menus::section_label("Your active shortcuts", shell_font(), palette.muted,)
.size(14),
container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height),
text("Disable this guide in Settings → Shortcuts. It appears at each login until disabled.")
.size(12)
.class(theme::Text::Color(palette.muted)),
row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button(
"Got it",
shell_font(),
palette,
true,
)
.on_press(cosmic::Action::App(Message::CancelPower)),
ferese_theme::controls::text_button("Got it", shell_font(), palette, true,)
.on_press(cosmic::Action::App(Message::CancelPower)),
],
]
.spacing(14)
@@ -547,10 +497,8 @@ impl FereseShell {
.max_width(if modal.is_guide() { 600 } else { 440 })
.padding(24)
.class(theme::Container::custom(move |_| container::Style {
background: (!material).then_some(Background::Color(color_with_opacity(
theme.surface_popover,
alpha,
))),
background: (!material)
.then_some(Background::Color(color_with_opacity(theme.surface_popover, alpha))),
text_color: Some(palette.text),
icon_color: Some(palette.text),
border: Border {
@@ -582,12 +530,7 @@ impl FereseShell {
.align_x(cosmic::iced::Alignment::Center)
.align_y(cosmic::iced::Alignment::Center)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color::from_rgba(
0.0,
0.0,
0.0,
0.34 * progress,
))),
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.34 * progress))),
..Default::default()
}));
centered.into()
@@ -612,10 +555,7 @@ mod tests {
PowerAction::from_status(status::Action::Suspend),
Some(PowerAction::Suspend)
);
assert_eq!(
PowerAction::from_status(status::Action::Brightness(50)),
None
);
assert_eq!(PowerAction::from_status(status::Action::Brightness(50)), None);
assert_eq!(PowerAction::Logout(1).label(), "Log out");
}
}