Unify shell opacity and add native authentication and system controls
No files matched your search
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(®ions) {
|
||||
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(®ions) {
|
||||
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(¬e.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 == ¬e.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(¬e_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)),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||