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

+18
View File
@@ -0,0 +1,18 @@
[package]
name = "ferese-polkit-agent"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
[dependencies]
ferese-config = { path = "../../crates/ferese-config" }
glib = "0.21.5"
libc.workspace = true
libcosmic = { workspace = true, features = ["wgpu"] }
libloading = "0.8.9"
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
zbus = "5.19.0"
zeroize = "1.9"
+172
View File
@@ -0,0 +1,172 @@
mod prompt;
mod session;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use zbus::zvariant::{OwnedValue, Value};
const AGENT_PATH: &str = "/dev/ferese/PolkitAgent";
const AUTHORITY: &str = "org.freedesktop.PolicyKit1";
const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority";
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum PromptEvent {
Start { message: String, user: String },
Request { prompt: String, echo: bool },
Info { text: String },
Error { text: String },
Response { value: String },
Cancel,
}
type Identity = (String, HashMap<String, OwnedValue>);
#[derive(Default)]
struct Agent {
active: Mutex<HashMap<String, Arc<AtomicBool>>>,
}
#[zbus::interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
impl Agent {
async fn begin_authentication(
&self,
_action_id: String,
message: String,
_icon_name: String,
_details: HashMap<String, String>,
cookie: String,
identities: Vec<Identity>,
) -> zbus::fdo::Result<()> {
let uid = choose_identity(&identities).ok_or_else(|| {
zbus::fdo::Error::Failed("No supported authentication identity".into())
})?;
let cancelled = Arc::new(AtomicBool::new(false));
{
let mut active = self.active.lock().unwrap();
if active.contains_key(&cookie) {
return Err(zbus::fdo::Error::Failed("Duplicate request".into()));
}
active.insert(cookie.clone(), cancelled.clone());
}
let result = tokio::task::spawn_blocking({
let cookie = cookie.clone();
move || {
session::authenticate(uid, message.chars().take(240).collect(), cookie, cancelled)
}
})
.await
.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
self.active.lock().unwrap().remove(&cookie);
match result {
Ok(true) => Ok(()),
Ok(false) => Err(zbus::fdo::Error::Failed("Authentication cancelled".into())),
Err(error) => Err(zbus::fdo::Error::Failed(error)),
}
}
fn cancel_authentication(&self, cookie: String) {
if let Some(cancelled) = self.active.lock().unwrap().get(&cookie) {
cancelled.store(true, Ordering::Release);
}
}
}
fn choose_identity(identities: &[Identity]) -> Option<u32> {
let available: Vec<_> = identities
.iter()
.filter(|(kind, _)| kind == "unix-user")
.filter_map(|(_, details)| {
details
.get("uid")
.and_then(|uid| u32::try_from(uid.clone()).ok())
.filter(|uid| *uid <= i32::MAX as u32)
})
.collect();
let current = unsafe { libc::geteuid() };
available
.iter()
.copied()
.find(|uid| *uid == current)
.or_else(|| available.first().copied())
}
fn session_subject() -> Result<(String, HashMap<String, Value<'static>>), String> {
let id = std::env::var("XDG_SESSION_ID")
.map_err(|_| "XDG_SESSION_ID is required for polkit registration".to_owned())?;
if id.is_empty() {
return Err("XDG_SESSION_ID is empty".into());
}
let mut details = HashMap::new();
details.insert("session-id".to_owned(), Value::from(id));
Ok(("unix-session".into(), details))
}
async fn run_agent() -> Result<(), Box<dyn std::error::Error>> {
let subject = session_subject()?;
let connection = zbus::connection::Builder::system()?
.serve_at(AGENT_PATH, Agent::default())?
.build()
.await?;
let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY).await?;
let locale = std::env::var("LANG").unwrap_or_default();
authority
.call::<_, _, ()>(
"RegisterAuthenticationAgent",
&(subject, locale, AGENT_PATH),
)
.await?;
std::future::pending::<()>().await;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
unsafe {
libc::prctl(libc::PR_SET_DUMPABLE, 0);
}
match std::env::args().nth(1).as_deref() {
None => tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(run_agent()),
Some("--prompt") => prompt::run(),
Some("--check") => session::check().map_err(Into::into),
_ => Err("Usage: ferese-polkit-agent [--check]".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chooses_current_identity_when_available() {
let current = unsafe { libc::geteuid() };
let identity = |uid: u32| {
(
"unix-user".to_owned(),
HashMap::from([("uid".to_owned(), OwnedValue::from(uid))]),
)
};
assert_eq!(
choose_identity(&[identity(0), identity(current)]),
Some(current)
);
}
}
+489
View File
@@ -0,0 +1,489 @@
use crate::PromptEvent;
use cosmic::{
Element,
app::{Core, Settings, Task},
iced::{Alignment, Background, Border, Color, Length, Size, Subscription, window},
theme,
widget::{self, button, column, container, row, text},
};
use std::{
collections::VecDeque,
io::{BufRead, BufReader, Write},
sync::{Arc, Mutex},
time::Duration,
};
use zeroize::{Zeroize, Zeroizing};
#[derive(Clone)]
struct Appearance {
surface: Color,
translucent: bool,
text: Color,
muted: Color,
accent: Color,
radius: f32,
font: cosmic::font::Font,
}
impl Appearance {
fn load() -> Self {
let document = ferese_config::config_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|source| ferese_config::Document::parse(&source).ok());
let setting = |key: &str, fallback: &str| {
document
.as_ref()
.and_then(|doc| doc.get(key))
.and_then(|value| value.as_str())
.unwrap_or(fallback)
.to_owned()
};
let color = |key, fallback| parse_color(&setting(key, fallback), fallback);
let family: &'static str =
Box::leak(setting("theme.typography.font_family", "Inter").into_boxed_str());
let translucent = setting("theme.material.style", "solid") == "translucent";
let shell_opacity = document
.as_ref()
.and_then(|doc| doc.get("theme.material.opacity"))
.and_then(|value| value.as_f64())
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
.clamp(0., 1.) as f32;
let mut surface = color("theme.colors.surface_base", "#111821");
surface.a = if translucent { shell_opacity } else { 1. };
Self {
surface,
translucent,
text: color("theme.colors.text_primary", "#F4F7FB"),
muted: color("theme.colors.text_muted", "#8793A2"),
accent: color("theme.colors.accent", "#3D7BE6"),
radius: document
.as_ref()
.and_then(|doc| doc.get("theme.geometry.shell_radius"))
.and_then(|value| value.as_f64())
.unwrap_or(14.)
.clamp(8., 36.) as f32,
font: cosmic::font::Font {
family: cosmic::iced::font::Family::Name(family),
..cosmic::font::default()
},
}
}
fn theme(&self) -> cosmic::Theme {
use cosmic::cosmic_theme::{ThemeBuilder, palette::Srgba};
let rgba = |color: Color| Srgba::new(color.r, color.g, color.b, color.a);
let builder = if self.surface.r + self.surface.g + self.surface.b > 1.5 {
ThemeBuilder::light()
} else {
ThemeBuilder::dark()
};
let mut corners = cosmic::cosmic_theme::CornerRadii::default();
corners.radius_xs = [self.radius.min(4.); 4];
corners.radius_s = [self.radius.min(8.); 4];
corners.radius_m = [self.radius; 4];
corners.radius_l = [self.radius; 4];
corners.radius_xl = [self.radius; 4];
cosmic::Theme::custom(Arc::new(
builder
.corner_radii(corners)
.bg_color(rgba(Color {
a: if self.translucent { 0. } else { 1. },
..self.surface
}))
.primary_container_bg(rgba(self.surface))
.text_tint(rgba(self.text).color)
.accent(rgba(self.accent).color)
.build(),
))
}
}
fn parse_color(input: &str, fallback: &str) -> Color {
let parse = |source: &str| {
let value = u32::from_str_radix(source.strip_prefix('#')?, 16).ok()?;
(source.len() == 7)
.then(|| Color::from_rgb8((value >> 16) as u8, (value >> 8) as u8, value as u8))
};
parse(input)
.or_else(|| parse(fallback))
.unwrap_or(Color::BLACK)
}
#[derive(Clone)]
enum Message {
Tick,
Input(String),
Submit,
Cancel,
}
impl std::fmt::Debug for Message {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("AuthenticationPromptMessage")
}
}
struct Prompt {
core: Core,
appearance: Appearance,
incoming: Arc<Mutex<VecDeque<PromptEvent>>>,
description: String,
user: String,
question: String,
echo: bool,
answer: Zeroizing<String>,
waiting: bool,
error: Option<String>,
info: Option<String>,
height: f32,
}
fn content_height(description: &str, status: Option<&str>) -> f32 {
let lines: usize = description
.lines()
.map(|line| line.chars().count().div_ceil(48).max(1))
.sum();
let status_height = status.map_or(0, |message| {
14 + 18 * message.chars().count().div_ceil(48).clamp(1, 3)
});
(214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32
}
fn input_style(palette: &Appearance) -> theme::TextInput {
let text = palette.text;
let muted = palette.muted;
let accent = palette.accent;
let radius = palette.radius.min(10.);
let appearance = move |focused: bool| widget::text_input::Appearance {
background: Color::from_rgba(text.r, text.g, text.b, 0.045).into(),
border_radius: radius.into(),
border_width: 1.,
border_offset: None,
border_color: if focused {
accent.scale_alpha(0.82)
} else {
muted.scale_alpha(0.26)
},
icon_color: Some(muted),
text_color: Some(text),
placeholder_color: muted,
selected_text_color: text,
selected_fill: accent.scale_alpha(0.35),
label_color: text,
};
theme::TextInput::Custom {
active: Box::new(move |_| appearance(false)),
hovered: Box::new(move |_| appearance(true)),
focused: Box::new(move |_| appearance(true)),
error: Box::new(move |_| appearance(false)),
disabled: Box::new(move |_| appearance(false)),
}
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let stdin = std::io::stdin();
let mut lines = BufReader::new(stdin).lines();
let Some(Ok(first)) = lines.next() else {
return Err("Missing authentication request".into());
};
let PromptEvent::Start { message, user } = serde_json::from_str(&first)? else {
return Err("Invalid authentication request".into());
};
let incoming = Arc::new(Mutex::new(VecDeque::new()));
let queue = incoming.clone();
std::thread::spawn(move || {
for line in lines {
let Ok(line) = line else { break };
if let Ok(event) = serde_json::from_str(&line) {
queue.lock().unwrap().push_back(event);
}
}
queue.lock().unwrap().push_back(PromptEvent::Cancel);
});
let appearance = Appearance::load();
let height = content_height(&message, None);
cosmic::app::run::<Prompt>(
Settings::default()
.size(Size::new(434., height))
.client_decorations(false)
.transparent(true)
.theme(appearance.theme())
.default_font(appearance.font)
.default_text_size(14.)
.antialiasing(true),
(incoming, message, user, appearance),
)?;
Ok(())
}
fn reply(mut event: PromptEvent) {
if let Ok(mut line) = serde_json::to_string(&event) {
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{line}");
let _ = stdout.flush();
line.zeroize();
}
if let PromptEvent::Response { value } = &mut event {
value.zeroize();
}
}
fn focus() -> Task<Message> {
widget::text_input::focus(widget::Id::new("auth-response"))
}
impl Prompt {
fn resize_to_content(&mut self) -> Task<Message> {
let height = content_height(
&self.description,
self.error.as_deref().or(self.info.as_deref()),
);
if (height - self.height).abs() < 1.0 {
return Task::none();
}
self.height = height;
self.core
.main_window_id()
.map(|id| window::resize(id, Size::new(434., height)))
.unwrap_or_else(Task::none)
}
}
impl cosmic::Application for Prompt {
type Executor = cosmic::executor::Default;
type Flags = (
Arc<Mutex<VecDeque<PromptEvent>>>,
String,
String,
Appearance,
);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.Authentication";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
fn init(
mut core: Core,
(incoming, description, user, appearance): Self::Flags,
) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
let height = content_height(&description, None);
let bounds = core
.main_window_id()
.map(|id| window::set_max_size(id, Some(Size::new(434., 360.))))
.unwrap_or_else(Task::none);
(
Self {
core,
appearance,
incoming,
description,
user,
question: "Waiting for authentication…".into(),
echo: false,
answer: Zeroizing::new(String::new()),
waiting: true,
error: None,
info: None,
height,
},
Task::batch([focus(), bounds]),
)
}
fn subscription(&self) -> Subscription<Message> {
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Tick => {
let events: Vec<_> = self.incoming.lock().unwrap().drain(..).collect();
let mut refocus = false;
for event in events {
match event {
PromptEvent::Request { prompt, echo } => {
self.answer.zeroize();
self.question = prompt;
self.echo = echo;
self.waiting = false;
refocus = true;
}
PromptEvent::Info { text } => self.info = Some(text),
PromptEvent::Error { text } => self.error = Some(text),
PromptEvent::Cancel => return cosmic::iced::exit(),
_ => {}
}
}
let resize = self.resize_to_content();
if refocus {
return Task::batch([resize, focus()]);
}
return resize;
}
Message::Input(mut value) => {
if !self.waiting && value.len() <= 4096 {
self.answer.zeroize();
*self.answer = std::mem::take(&mut value);
self.error = None;
}
value.zeroize();
}
Message::Submit if !self.waiting && !self.answer.is_empty() => {
let value = std::mem::take(&mut *self.answer);
reply(PromptEvent::Response { value });
self.waiting = true;
}
Message::Cancel => {
self.answer.zeroize();
reply(PromptEvent::Cancel);
return cosmic::iced::exit();
}
_ => {}
}
Task::none()
}
fn on_escape(&mut self) -> Task<Message> {
self.update(Message::Cancel)
}
fn view(&self) -> Element<'_, Message> {
let palette = &self.appearance;
let mut input = widget::text_input(&self.question, self.answer.as_str())
.id(widget::Id::new("auth-response"))
.font(palette.font)
.padding([7, 12])
.style(input_style(palette));
if !self.echo {
input = input.password();
}
if !self.waiting {
input = input.on_input(Message::Input).on_submit(|mut value| {
value.zeroize();
Message::Submit
});
}
let action = button::custom(text("Authenticate").font(palette.font).size(13))
.class(theme::Button::Suggested)
.height(Length::Fixed(36.))
.padding([8, 16])
.on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit));
let lock_svg = std::str::from_utf8(include_bytes!(
"../../ferese-shell/assets/icons/status/lock.svg"
))
.expect("embedded lock icon is valid UTF-8")
.replace(
"currentColor",
&format!(
"#{:02x}{:02x}{:02x}",
(palette.accent.r * 255.) as u8,
(palette.accent.g * 255.) as u8,
(palette.accent.b * 255.) as u8,
),
);
let lock_icon = widget::icon::from_svg_bytes(lock_svg.into_bytes())
.symbolic(false)
.icon()
.size(20);
let heading = row![
container(lock_icon)
.width(40)
.height(40)
.center_x(40)
.center_y(40)
.class(theme::Container::custom(move |_| {
widget::container::Style {
background: Some(Background::Color(palette.accent.scale_alpha(0.13))),
border: Border {
radius: 12.0.into(),
..Default::default()
},
..Default::default()
}
})),
column![
text("Authentication required").font(palette.font).size(18),
text(format!("Confirm as {}", self.user))
.font(palette.font)
.size(12)
.class(theme::Text::Color(palette.muted)),
]
.spacing(2),
]
.spacing(12)
.align_y(Alignment::Center);
let mut body = column![
heading,
text(&self.description).font(palette.font).size(13),
input
]
.spacing(14)
.width(Length::Fill);
if let Some(error) = &self.error {
body = body.push(
text(error)
.font(palette.font)
.size(12)
.class(theme::Text::Color(Color::from_rgb8(235, 98, 98))),
);
} else if let Some(info) = &self.info {
body = body.push(
text(info)
.font(palette.font)
.size(12)
.class(theme::Text::Color(palette.muted)),
);
}
body = body.push(
row![
button::custom(text("Cancel").font(palette.font).size(13))
.class(theme::Button::Text)
.padding([8, 14])
.on_press(Message::Cancel),
action
]
.spacing(8)
.align_y(Alignment::Center),
);
container(body)
.padding(20)
.width(Length::Fill)
.height(Length::Fill)
.class(theme::Container::custom(move |_| {
widget::container::Style {
background: Some(Background::Color(palette.surface)),
text_color: Some(palette.text),
border: Border {
radius: palette.radius.into(),
..Default::default()
},
..Default::default()
}
}))
.into()
}
}
#[cfg(test)]
mod tests {
use super::content_height;
#[test]
fn dialog_grows_for_long_messages_and_caps_its_height() {
assert_eq!(content_height("Short request", None), 214.);
assert!(content_height(&"Long message ".repeat(20), None) > 214.);
assert_eq!(content_height(&"Long message ".repeat(100), None), 360.);
assert!(content_height("Short request", Some("Password not accepted")) > 214.);
}
}
+325
View File
@@ -0,0 +1,325 @@
use crate::PromptEvent;
use glib::gobject_ffi;
use libloading::Library;
use std::{
ffi::{CStr, CString, c_char, c_int, c_void},
io::{BufRead, BufReader, BufWriter, Write},
process::{Child, ChildStdin, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Sender},
},
time::Duration,
};
use zeroize::Zeroize;
enum SessionEvent {
Request(String, bool),
Info(String),
Error(String),
Completed(bool),
}
type Object = *mut c_void;
struct Api {
_agent: Library,
_polkit: Library,
user_new: unsafe extern "C" fn(c_int) -> Object,
session_new: unsafe extern "C" fn(Object, *const c_char) -> Object,
initiate: unsafe extern "C" fn(Object),
response: unsafe extern "C" fn(Object, *const c_char),
cancel: unsafe extern "C" fn(Object),
}
impl Api {
fn load() -> Result<Self, String> {
unsafe {
let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?;
let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?;
let user_new = *polkit
.get(b"polkit_unix_user_new\0")
.map_err(|e| e.to_string())?;
let session_new = *agent
.get(b"polkit_agent_session_new\0")
.map_err(|e| e.to_string())?;
let initiate = *agent
.get(b"polkit_agent_session_initiate\0")
.map_err(|e| e.to_string())?;
let response = *agent
.get(b"polkit_agent_session_response\0")
.map_err(|e| e.to_string())?;
let cancel = *agent
.get(b"polkit_agent_session_cancel\0")
.map_err(|e| e.to_string())?;
Ok(Self {
_agent: agent,
_polkit: polkit,
user_new,
session_new,
initiate,
response,
cancel,
})
}
}
}
pub fn check() -> Result<(), String> {
Api::load().map(|_| ())
}
fn username(uid: u32) -> String {
let output = Command::new("getent")
.args(["passwd", &uid.to_string()])
.output();
output
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.and_then(|entry| entry.split(':').next().map(str::to_owned))
.filter(|name| !name.is_empty())
.unwrap_or_else(|| uid.to_string())
}
fn send(writer: &mut BufWriter<ChildStdin>, event: &PromptEvent) -> Result<(), String> {
serde_json::to_writer(&mut *writer, event).map_err(|e| e.to_string())?;
writer.write_all(b"\n").map_err(|e| e.to_string())?;
writer.flush().map_err(|e| e.to_string())
}
fn prompt_process(
message: String,
user: String,
) -> Result<(Child, BufWriter<ChildStdin>, mpsc::Receiver<PromptEvent>), String> {
let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?)
.arg("--prompt")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| e.to_string())?;
let stdin = child.stdin.take().ok_or("Prompt has no input")?;
let stdout = child.stdout.take().ok_or("Prompt has no output")?;
let mut writer = BufWriter::new(stdin);
send(&mut writer, &PromptEvent::Start { message, user })?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
let Ok(mut line) = line else { break };
if let Ok(event) = serde_json::from_str(&line) {
line.zeroize();
if tx.send(event).is_err() {
break;
}
} else {
line.zeroize();
}
}
});
Ok((child, writer, rx))
}
unsafe extern "C" fn request(
_session: Object,
prompt: *const c_char,
echo: c_int,
data: *mut c_void,
) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let prompt = unsafe { CStr::from_ptr(prompt) }
.to_string_lossy()
.into_owned();
let _ = tx.send(SessionEvent::Request(prompt, echo != 0));
}
unsafe extern "C" fn info(_session: Object, text: *const c_char, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let text = unsafe { CStr::from_ptr(text) }
.to_string_lossy()
.into_owned();
let _ = tx.send(SessionEvent::Info(text));
}
unsafe extern "C" fn error(_session: Object, text: *const c_char, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let text = unsafe { CStr::from_ptr(text) }
.to_string_lossy()
.into_owned();
let _ = tx.send(SessionEvent::Error(text));
}
unsafe extern "C" fn completed(_session: Object, success: c_int, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let _ = tx.send(SessionEvent::Completed(success != 0));
}
unsafe fn connect(
session: Object,
name: &'static CStr,
callback: gobject_ffi::GCallback,
data: *mut c_void,
) {
unsafe {
gobject_ffi::g_signal_connect_data(
session.cast(),
name.as_ptr(),
callback,
data,
None,
gobject_ffi::G_CONNECT_DEFAULT,
);
}
}
fn run_session(
api: &Api,
uid: u32,
cookie: &CString,
context: &glib::MainContext,
writer: &mut BufWriter<ChildStdin>,
input: &mpsc::Receiver<PromptEvent>,
cancelled: &AtomicBool,
child: &mut Child,
) -> Result<Option<bool>, String> {
let identity = unsafe { (api.user_new)(uid as c_int) };
if identity.is_null() {
return Err("Could not create polkit identity".into());
}
let session = unsafe { (api.session_new)(identity, cookie.as_ptr()) };
unsafe { gobject_ffi::g_object_unref(identity.cast()) };
if session.is_null() {
return Err("Could not create polkit session".into());
}
let (tx, rx) = mpsc::channel();
let signal_data = Box::into_raw(Box::new(tx)).cast();
unsafe {
connect(
session,
c"request",
Some(std::mem::transmute(
request as unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void),
)),
signal_data,
);
connect(
session,
c"show-info",
Some(std::mem::transmute(
info as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
)),
signal_data,
);
connect(
session,
c"show-error",
Some(std::mem::transmute(
error as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
)),
signal_data,
);
connect(
session,
c"completed",
Some(std::mem::transmute(
completed as unsafe extern "C" fn(Object, c_int, *mut c_void),
)),
signal_data,
);
(api.initiate)(session);
}
let mut outcome = None;
let mut stopped = false;
while outcome.is_none() {
while context.pending() {
context.iteration(false);
}
while let Ok(event) = rx.try_recv() {
match event {
SessionEvent::Request(prompt, echo) => {
send(writer, &PromptEvent::Request { prompt, echo })?;
}
SessionEvent::Info(text) => send(writer, &PromptEvent::Info { text })?,
SessionEvent::Error(text) => send(writer, &PromptEvent::Error { text })?,
SessionEvent::Completed(success) => outcome = Some(success),
}
}
if outcome.is_some() {
break;
}
while let Ok(event) = input.try_recv() {
match event {
PromptEvent::Response { mut value } => {
if let Ok(response) = CString::new(value.as_str()) {
unsafe { (api.response)(session, response.as_ptr()) };
}
value.zeroize();
}
PromptEvent::Cancel => cancelled.store(true, Ordering::Release),
_ => {}
}
}
if !stopped
&& (cancelled.load(Ordering::Acquire)
|| child.try_wait().map_err(|e| e.to_string())?.is_some())
{
unsafe { (api.cancel)(session) };
stopped = true;
}
std::thread::sleep(Duration::from_millis(10));
}
unsafe {
gobject_ffi::g_object_unref(session.cast());
drop(Box::from_raw(signal_data as *mut Sender<SessionEvent>));
}
Ok(outcome.filter(|_| !stopped))
}
pub fn authenticate(
uid: u32,
message: String,
cookie: String,
cancelled: Arc<AtomicBool>,
) -> Result<bool, String> {
let api = Api::load()?;
let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?;
let context = glib::MainContext::new();
context
.with_thread_default(|| {
let (mut child, mut writer, input) = prompt_process(message, username(uid))?;
let result = loop {
match run_session(
&api,
uid,
&cookie,
&context,
&mut writer,
&input,
&cancelled,
&mut child,
) {
Ok(Some(true)) => break Ok(true),
Ok(Some(false)) if !cancelled.load(Ordering::Acquire) => {
if let Err(error) = send(
&mut writer,
&PromptEvent::Error {
text: "Password not accepted. Try again.".into(),
},
) {
break Err(error);
}
}
Ok(_) => break Ok(false),
Err(error) => break Err(error),
}
};
let _ = child.kill();
let _ = child.wait();
result
})
.map_err(|error| error.to_string())?
}
+11 -11
View File
@@ -623,6 +623,17 @@ pub fn fields(page: Page) -> Vec<Field> {
"solid",
&[("solid", "Solid"), ("translucent", "Translucent")],
),
range(
"theme.material.opacity",
"Shell opacity",
"Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.",
ferese_config::DEFAULT_MATERIAL_OPACITY,
0.0,
1.0,
0.01,
"",
false,
),
range(
"theme.material.blur_radius",
"Background blur",
@@ -727,17 +738,6 @@ pub fn fields(page: Page) -> Vec<Field> {
" px",
true,
),
range(
"theme.surface.bar.opacity",
"Opacity",
"Background opacity for translucent bars, with or without blur. Text and icons stay opaque.",
0.78,
0.0,
1.0,
0.01,
"",
false,
),
range(
"theme.geometry.top_bar_window_gap",
"Window clearance",
+14 -1
View File
@@ -151,6 +151,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
} else {
(background, p.text)
};
button::Style {
background: Some(Background::Color(background)),
text_color: Some(on),
@@ -196,6 +197,7 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
.text_tint(cosmic_color(palette.text).color)
.accent(ferese_theme::accent_color(palette.accent, palette.card))
.build();
ferese_theme::apply(&mut theme, palette.text);
cosmic::Theme::custom(std::sync::Arc::new(theme))
}
@@ -204,16 +206,20 @@ pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
static FONTS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
> = std::sync::OnceLock::new();
let family = snapshot.string("theme.typography.font_family", "Inter");
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
if let Some(font) = fonts.get(&family) {
return *font;
}
if fonts.len() >= 32 {
return cosmic::font::default();
}
let font = cosmic::font::Font::with_name(Box::leak(family.clone().into_boxed_str()));
fonts.insert(family, font);
font
}
@@ -331,6 +337,7 @@ pub fn preset(index: usize) -> Vec<Edit> {
let Some(preset) = PRESETS.get(index) else {
return Vec::new();
};
vec![
set("theme.colors.accent", preset.accent),
set("theme.colors.surface_base", preset.base),
@@ -351,6 +358,7 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
let Some(preset) = PRESETS.get(index) else {
return false;
};
[
("theme.colors.accent", preset.accent, "#3D7BE6"),
("theme.colors.surface_base", preset.base, "#111821"),
@@ -442,7 +450,11 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.;
let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65;
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
let opacity = snapshot.number("theme.surface.bar.opacity", 0.78);
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
snapshot.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY).clamp(0., 1.)
} else {
1.
};
let right = 340. + gap / 2.;
let left_width = 288. - gap / 2.;
let svg = format!(
@@ -465,6 +477,7 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
bar_y + bar_height / 2.,
right + 16.
);
svg_icon::from_svg_bytes(svg.into_bytes())
.symbolic(false)
.icon()
+3
View File
@@ -6,6 +6,7 @@ use std::{path::PathBuf, time::Duration};
pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
let path = path.clone();
cosmic::iced::stream::channel(1, async move |mut output| {
let (send, mut events) = tokio::sync::mpsc::channel(1);
let target = path.clone();
@@ -26,6 +27,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
return;
}
};
if let Err(error) = watcher.watch(
path.parent().unwrap_or(std::path::Path::new(".")),
RecursiveMode::NonRecursive,
@@ -35,6 +37,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
.await;
return;
}
// Read once after installing the watch to cover the startup race.
loop {
let file = path.clone();
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 10h18"/><path d="M8 14h2m4 0h2m-8 4h2m4 0h2"/></svg>

After

Width:  |  Height:  |  Size: 287 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M7 12h10"/></svg>

After

Width:  |  Height:  |  Size: 226 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v9"/><path d="M7 5.5a9 9 0 1 0 10 0"/></svg>

After

Width:  |  Height:  |  Size: 219 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 1 0 9 9"/><path d="M12 12 18 6"/><circle cx="12" cy="12" r="1"/></svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 4c-8 0-14 3-14 10a6 6 0 0 0 6 6c7 0 10-6 8-16Z"/><path d="M4 21c2-5 6-8 12-11"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 1-2.3-5.7"/><path d="M20 4v5h-5"/></svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></svg>

After

Width:  |  Height:  |  Size: 230 B

+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);
}