Add live config reload, session locking and managed daemons

Refine focus gradients, shell workspace controls and popup motion. Correct floating stacking and preserve client-chosen dialog sizes. Add lock protocol probes and session integration.
This commit is contained in:
Samuel Onoja committed 2026-09-26 19:03:37 +01:00
1 parent b422448d88
commit 672b4e2b60
41 files changed
+2694 -298

No files matched your search

+18 -7
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug, Default)]
pub(crate) struct ShellConfig {
pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig,
pub(crate) theme: ShellTheme,
@@ -111,6 +112,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)]
struct FereseConfig {
#[serde(default)]
animations: crate::motion::Settings,
#[serde(default)]
appearance: AppearanceConfig,
#[serde(default)]
@@ -284,13 +287,24 @@ pub(crate) fn load() -> ShellConfig {
return ShellConfig::default();
};
match toml::from_str::<FereseConfig>(&source) {
match parse_source(&source) {
Ok(config) => config,
Err(error) => {
eprintln!("ferese-shell: failed to read {}: {error}", path.display());
ShellConfig::default()
}
}
}
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, toml::de::Error> {
match toml::from_str::<FereseConfig>(source) {
Ok(config) => {
let mut theme = shell_theme(&config.theme);
theme.material_radius =
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
ShellConfig {
Ok(ShellConfig {
animations: config.animations,
font_family: config.theme.typography.font_family,
wallpaper: config.theme.background,
theme,
@@ -298,12 +312,9 @@ pub(crate) fn load() -> ShellConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status
},
}
}
Err(error) => {
eprintln!("ferese-shell: failed to read {}: {error}", path.display());
ShellConfig::default()
})
}
Err(error) => Err(error),
}
}
+50 -2
View File
@@ -36,6 +36,7 @@ pub(crate) struct OutputSnapshot {
#[derive(Clone, Debug)]
pub(crate) struct WorkspaceSnapshot {
pub(crate) id: u64,
pub(crate) name: String,
pub(crate) output: Option<u64>,
pub(crate) active: bool,
}
@@ -58,12 +59,14 @@ pub(crate) struct ShellControl {
}
pub(crate) struct ControlPoll {
pub(crate) config: Option<String>,
pub(crate) snapshot: Option<ShellSnapshot>,
pub(crate) overview_active: Option<bool>,
pub(crate) disconnected: bool,
}
enum ControlUpdate {
Config(String),
Snapshot(ShellSnapshot),
OverviewState(bool),
Disconnected,
@@ -74,7 +77,7 @@ impl ShellControl {
let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=1, ())?;
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=2, ())?;
let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel();
let mut state = ControlState::new(sender);
@@ -97,6 +100,7 @@ impl ShellControl {
pub(crate) fn poll(&self) -> ControlPoll {
let mut poll = ControlPoll {
config: None,
snapshot: None,
overview_active: None,
disconnected: false,
@@ -104,6 +108,7 @@ impl ShellControl {
loop {
match self.updates.try_recv() {
Ok(ControlUpdate::Config(source)) => poll.config = Some(source),
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => {
@@ -156,6 +161,7 @@ fn control_connection() -> Result<Connection, Box<dyn Error>> {
}
struct ControlState {
config: Option<String>,
sender: Sender<ControlUpdate>,
pending: ShellSnapshot,
serial: Option<u32>,
@@ -165,6 +171,7 @@ impl ControlState {
fn new(sender: Sender<ControlUpdate>) -> Self {
Self {
sender,
config: None,
pending: ShellSnapshot::default(),
serial: None,
}
@@ -193,6 +200,15 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
_qh: &QueueHandle<Self>,
) {
match event {
ferese_shell_v1::Event::ConfigBegin => state.config = Some(String::new()),
ferese_shell_v1::Event::ConfigChunk { source } => {
append_config_chunk(&mut state.config, &source);
}
ferese_shell_v1::Event::ConfigEnd => {
if let Some(source) = state.config.take() {
let _ = state.sender.send(ControlUpdate::Config(source));
}
}
ferese_shell_v1::Event::SnapshotBegin { serial } => {
state.serial = Some(serial);
state.pending = ShellSnapshot::default();
@@ -215,13 +231,14 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
workspace_lo,
output_hi,
output_lo,
name: _,
name,
active,
} => {
let output = join_id(output_hi, output_lo);
state.pending.workspaces.push(WorkspaceSnapshot {
id: join_id(workspace_hi, workspace_lo),
name,
output: (output != 0).then_some(output),
active: active != 0,
});
@@ -276,3 +293,34 @@ fn split_id(id: u64) -> (u32, u32) {
fn join_id(hi: u32, lo: u32) -> u64 {
(u64::from(hi) << 32) | u64::from(lo)
}
fn append_config_chunk(config: &mut Option<String>, source: &str) {
if let Some(buffer) = config {
if buffer.len() + source.len() > 60 * 1024 {
*config = None;
} else {
buffer.push_str(source);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_transfer_is_bounded_and_requires_begin() {
let mut config = None;
append_config_chunk(&mut config, "ignored");
assert!(config.is_none());
config = Some(String::new());
append_config_chunk(&mut config, "🌲");
append_config_chunk(&mut config, "theme");
assert_eq!(config.take().as_deref(), Some("🌲theme"));
config = Some("a".repeat(60 * 1024));
append_config_chunk(&mut config, "overflow");
assert!(config.is_none());
append_config_chunk(&mut config, "ignored after overflow");
assert!(config.is_none());
}
}
+208 -161
View File
@@ -19,7 +19,9 @@ use cosmic::iced::event::{self, PlatformSpecific, wayland};
use cosmic::iced::platform_specific::{
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings},
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
shell::commands::layer_surface::{destroy_layer_surface, set_input_zone},
shell::commands::layer_surface::{
destroy_layer_surface, set_exclusive_zone, set_input_zone, set_margin, set_size,
},
};
use cosmic::iced::{
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window,
@@ -42,12 +44,27 @@ use crate::control::{ShellControl, ShellSnapshot};
const APP_ID: &str = "dev.ferese.Shell";
static SHELL_FONT: std::sync::OnceLock<cosmic::font::Font> = std::sync::OnceLock::new();
static SHELL_FONT: std::sync::OnceLock<std::sync::RwLock<cosmic::font::Font>> =
std::sync::OnceLock::new();
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
family.map_or_else(cosmic::font::default, |family| {
// Font names require static storage. Intern repeated families and cap
// the cache so live theme edits cannot leak unlimited strings.
static FONTS: std::sync::OnceLock<
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());
cosmic::font::Font::with_name(family)
let font = cosmic::font::Font::with_name(family);
fonts.insert(family.to_owned(), font);
font
})
}
@@ -56,7 +73,12 @@ fn configured_font(family: Option<&str>) -> cosmic::font::Font {
fn text<'a>(
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
cosmic::widget::text(content).font(*SHELL_FONT.get_or_init(cosmic::font::default))
cosmic::widget::text(content).font(
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
.read()
.unwrap(),
)
}
#[derive(Clone, Copy)]
@@ -66,11 +88,6 @@ struct BarMetrics {
icon_size: u16,
overview_icon_size: u16,
control_height: f32,
workspace_hit_width: f32,
active_marker_width: f32,
active_marker_height: f32,
dot_marker_size: f32,
empty_marker_size: f32,
}
impl From<ShellTheme> for BarMetrics {
@@ -85,11 +102,6 @@ impl From<ShellTheme> for BarMetrics {
icon_size: 19,
overview_icon_size: 19,
control_height: (height - 4.0).max(21.0).min(height),
workspace_hit_width: 22.0,
active_marker_width: 14.0,
active_marker_height: 4.0,
dot_marker_size: 4.0,
empty_marker_size: 3.0,
}
}
}
@@ -103,6 +115,7 @@ fn main() -> cosmic::iced::Result {
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
}
let mut config = config::load();
motion::configure(config.animations);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
if compositor_wallpaper {
// The compositor owns one GPU image, shared across output renderers;
@@ -110,7 +123,7 @@ fn main() -> cosmic::iced::Result {
config.wallpaper.path = None;
}
let shell_font = configured_font(config.font_family.as_deref());
let _ = SHELL_FONT.set(shell_font);
let _ = SHELL_FONT.set(std::sync::RwLock::new(shell_font));
// Decode alongside toolkit/GPU initialization, never during a UI draw.
let wallpaper = config.wallpaper.path.clone().map(|path| {
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
@@ -173,7 +186,6 @@ enum Message {
),
Tick,
ActivateWorkspace(u64),
ActivateWindow(u64),
ToggleOverview,
StatusTick,
AnimateMenu,
@@ -252,7 +264,11 @@ impl cosmic::Application for FereseShell {
}),
cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick),
cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::StatusTick),
if self.menu.as_ref().is_some_and(|menu| menu.animating()) {
if self
.menu
.as_ref()
.is_some_and(|menu| menu.animating() || menu.motion.closing())
{
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
} else {
Subscription::none()
@@ -264,6 +280,9 @@ impl cosmic::Application for FereseShell {
if let Some(menu) = &self.menu
&& let Some(effects) = &menu.effects
{
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}");
@@ -295,6 +314,9 @@ impl cosmic::Application for FereseShell {
eprintln!("ferese-shell: popover material unavailable: {error}")
}
}
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
}
}
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
@@ -310,7 +332,7 @@ impl cosmic::Application for FereseShell {
}
Task::none()
}
Message::AnimateMenu => Task::none(),
Message::AnimateMenu => self.animate_menu(),
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
Message::OpenMenuOn(id, kind, anchor) => {
if self.bar_surface_id != id {
@@ -343,13 +365,14 @@ impl cosmic::Application for FereseShell {
action,
status::Action::Notifications | status::Action::Settings
) {
return self.destroy_menu();
return self.close_menu();
}
Task::none()
}
Message::Event(event, id) => self.handle_event(event, id),
Message::Tick => {
self.clock = current_time();
let mut reload_task = Task::none();
if let Some(control) = &self.control {
let poll = control.poll();
@@ -359,9 +382,12 @@ impl cosmic::Application for FereseShell {
if let Some(active) = poll.overview_active {
self.overview_active = active;
}
if let Some(source) = poll.config {
reload_task = self.reload_config(source);
}
if let Some(snapshot) = poll.snapshot {
self.snapshot = snapshot;
let mut tasks = Vec::new();
let mut tasks = vec![reload_task];
for entry in &mut self.outputs {
let output = self
.snapshot
@@ -392,13 +418,7 @@ impl cosmic::Application for FereseShell {
return Task::batch(tasks);
}
}
Task::none()
}
Message::ActivateWindow(id) => {
if let Some(control) = &self.control {
control.activate_window(id);
}
Task::none()
reload_task
}
Message::ActivateWorkspace(id) => {
if let Some(control) = &self.control {
@@ -432,6 +452,62 @@ impl cosmic::Application for FereseShell {
}
impl FereseShell {
fn reload_config(&mut self, source: String) -> Task<Message> {
let mut config = match config::parse_source(&source) {
Ok(config) => config,
Err(error) => {
eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
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()))
.write()
.unwrap() = configured_font(config.font_family.as_deref());
}
motion::configure(config.animations);
if self.config.animations != config.animations
&& let Some(menu) = &mut self.menu
{
menu.motion.update_settings(config.animations);
}
self.status_service
.update_settings(config.status.settings_command.clone());
let old = self.config.theme;
let theme = config.theme;
let geometry_changed = old.bar_height != theme.bar_height
|| old.bar_margin_top != theme.bar_margin_top
|| old.bar_margin_horizontal != theme.bar_margin_horizontal
|| old.bar_window_gap != theme.bar_window_gap;
self.config = config;
if !geometry_changed {
return Task::none();
}
let mut tasks = Vec::new();
for entry in &self.outputs {
tasks.push(set_size(
entry.bar,
None,
Some(theme.bar_height.round() as u32),
));
tasks.push(set_margin(
entry.bar,
theme.bar_margin_top,
theme.bar_margin_horizontal,
0,
theme.bar_margin_horizontal,
));
tasks.push(set_exclusive_zone(
entry.bar,
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap),
));
}
Task::batch(tasks)
}
fn output_event(
&mut self,
event: wayland::OutputEvent,
@@ -577,13 +653,23 @@ impl FereseShell {
if self.outputs.iter().any(|entry| entry.bar == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
{
// Iced emits the first xdg_popup configure as Window::Opened,
// not Popup::Configured. This is the popup's ready signal.
if let Some(menu) = &mut self.menu
&& menu.id == id
{
menu.motion.begin(Instant::now());
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
}
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
}
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
..
}) if self.menu.is_some() => self.destroy_menu(),
}) if self.menu.is_some() => self.close_menu(),
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
event,
surface,
@@ -596,6 +682,7 @@ impl FereseShell {
wayland::PopupEvent::Done => {
self.menu = None;
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
return Task::none();
}
wayland::PopupEvent::Focused => {
if menu.effects.is_none() {
@@ -612,6 +699,7 @@ impl FereseShell {
}
}
wayland::PopupEvent::Configured { .. } => {
menu.motion.begin(Instant::now());
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
@@ -627,6 +715,9 @@ impl FereseShell {
}
_ => {}
}
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
}
Task::none()
}
@@ -706,25 +797,23 @@ impl FereseShell {
let shell_theme = self.config.theme.for_bar();
let bar = BarMetrics::from(shell_theme);
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
.spacing(1)
.spacing(3)
.align_y(cosmic::iced::Alignment::Center);
workspace_row = workspace_row.push(
workspace_row = workspace_row.push(motion::button(
button::custom(overview_control(bar, color(shell_theme.text_primary)))
.height(bar.control_height)
.padding([0, 7])
.class(status_ui::button_style(
color(shell_theme.text_primary),
self.overview_active,
1.0,
))
.on_press(cosmic::Action::App(Message::ToggleOverview)),
);
color(shell_theme.text_primary),
self.overview_active,
1.0,
));
for workspace in self
.snapshot
.workspaces
.iter()
.filter(|workspace| self.overview_active && workspace.output == focused_output_id)
.filter(|workspace| workspace_on_bar(workspace.output, focused_output_id))
{
let occupied = self
.snapshot
@@ -733,50 +822,35 @@ impl FereseShell {
.any(|window| window.workspace == workspace.id);
let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme);
let workspace_button = button::custom(indicator)
.name(format!(
"Workspace {}{}",
workspace.name,
if workspace.active { ", active" } else { "" }
))
.height(bar.control_height)
.padding(0)
.class(status_ui::button_style(
color(shell_theme.text_primary),
false,
1.0,
))
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
workspace.id,
)));
workspace_row = workspace_row.push(workspace_button);
let selector = motion::button(
workspace_button,
color(if workspace.active {
shell_theme.accent
} else {
shell_theme.text_primary
}),
false,
1.0,
);
// Iced tooltips are overlays inside this bar-height layer surface;
// viewport clamping puts them back over the selector. Keep the
// accessible button name, without an overlay stealing its target.
workspace_row = workspace_row.push(selector);
}
let foreground = color(shell_theme.text_primary);
let mut windows = row::with_capacity(self.snapshot.windows.len())
.spacing(4)
.align_y(cosmic::iced::Alignment::Center);
for window in self.snapshot.windows.iter().filter(|window| {
focused_output.is_some_and(|output| window.workspace == output.active_workspace)
}) {
let label = compact_window_label(window);
let foreground = color(if window.focused {
shell_theme.text_primary
} else {
shell_theme.text_muted
});
windows = windows.push(
button::custom(
container(
text(label)
.size(bar.text_size)
.wrapping(cosmic::iced::core::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
)
.center_y(bar.control_height),
)
.padding([0, 8])
.height(bar.control_height)
.class(status_ui::button_style(foreground, window.focused, 1.0))
.on_press(cosmic::Action::App(Message::ActivateWindow(window.id))),
);
}
let windows = cosmic::iced::widget::scrollable(windows)
let left = cosmic::iced::widget::scrollable(workspace_row)
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
cosmic::iced::widget::scrollable::Scrollbar::default()
.width(0)
@@ -784,9 +858,6 @@ impl FereseShell {
))
.width(Length::Fill)
.height(bar.control_height);
let left = row![workspace_row, windows]
.spacing(8)
.align_y(cosmic::iced::Alignment::Center);
let clock = container(
text(&self.clock)
.size(bar.text_size)
@@ -943,70 +1014,46 @@ fn workspace_indicator(
bar: BarMetrics,
shell_theme: ShellTheme,
) -> Element<'static, cosmic::Action<Message>> {
let (width, height, color, radius) = if active {
(
bar.active_marker_width,
bar.active_marker_height,
color(shell_theme.accent),
2.0,
)
} else if occupied {
(
bar.dot_marker_size,
bar.dot_marker_size,
color_with_opacity(shell_theme.text_muted, 0.78),
3.0,
)
} else {
(
bar.empty_marker_size,
bar.empty_marker_size,
color_with_opacity(shell_theme.text_muted, 0.42),
2.0,
)
};
let marker = container(text(""))
.width(width)
.height(height)
.width(if active { 18.0 } else { 6.0 })
.height(6)
.class(theme::Container::custom(move |_| {
workspace_marker_style(color, radius)
workspace_selector_style(active, occupied, shell_theme)
}));
container(marker)
.width(bar.workspace_hit_width)
.width(28)
.height(bar.control_height)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center)
.into()
}
fn workspace_marker_style(color: Color, radius: f32) -> container::Style {
container::Style {
background: Some(Background::Color(color)),
border: Border {
radius: radius.into(),
..Border::default()
},
snap: true,
..container::Style::default()
}
fn workspace_on_bar(workspace_output: Option<u64>, bar_output: Option<u64>) -> bool {
matches!((workspace_output, bar_output), (Some(workspace), Some(bar)) if workspace == bar)
}
fn display_app_name(window: &control::WindowSnapshot) -> String {
let identity = window
.app_id
.rsplit('.')
.find(|part| !part.is_empty())
.unwrap_or(&window.app_id);
if identity.is_empty() {
window.title.clone()
} else {
let mut characters = identity.chars();
characters
.next()
.map(|first| first.to_uppercase().collect::<String>() + characters.as_str())
.unwrap_or_else(|| "Desktop".to_owned())
fn workspace_selector_style(
active: bool,
occupied: bool,
shell_theme: ShellTheme,
) -> container::Style {
container::Style {
background: if active {
Some(Background::Color(color(shell_theme.accent)))
} else if occupied {
Some(Background::Color(color_with_opacity(
shell_theme.text_primary,
0.78,
)))
} else {
None
},
border: Border {
color: color_with_opacity(shell_theme.text_muted, 0.55),
width: if !active && !occupied { 1.0 } else { 0.0 },
radius: 3.0.into(),
},
..Default::default()
}
}
@@ -1030,22 +1077,6 @@ fn format_bar_time(now: &Zoned) -> String {
)
}
fn compact_window_label(window: &crate::control::WindowSnapshot) -> String {
let title = window.title.trim();
let label = if title.is_empty() {
display_app_name(window)
} else {
title.to_owned()
};
let mut characters = label.chars();
let prefix: String = characters.by_ref().take(24).collect();
if characters.next().is_some() {
format!("{prefix}…")
} else {
prefix
}
}
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
cosmic::iced::theme::Style {
background_color: Color::TRANSPARENT,
@@ -1131,6 +1162,7 @@ struct EffectsBinding {
surface: FereseSurfaceEffectsV1,
_queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>,
}
impl EffectsBinding {
@@ -1155,13 +1187,16 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=2, ())?;
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=3, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
if let Some(role) = role {
effects.set_role(role);
} else {
effects.clear_role();
if effects.version() >= 3 {
effects.set_opacity(0);
}
}
connection.flush()?;
@@ -1171,6 +1206,7 @@ impl EffectsBinding {
surface: effects,
_queue: queue,
regions: Default::default(),
opacity: std::cell::Cell::new(None),
})
}
@@ -1196,6 +1232,7 @@ impl EffectsBinding {
}
fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
self.set_opacity(1.0)?;
if visible {
self.surface
.set_role(ferese_surface_effects_v1::Role::Panel);
@@ -1206,6 +1243,15 @@ impl EffectsBinding {
self.connection.flush()?;
Ok(())
}
fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> {
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) {
self.surface.set_opacity(opacity);
self.connection.flush()?;
self.opacity.set(Some(opacity));
}
Ok(())
}
}
struct EffectsState;
@@ -1300,26 +1346,27 @@ mod tests {
}
#[test]
fn window_list_labels_use_titles_and_fall_back_to_the_app_name() {
let snapshot = snapshot_with_fullscreen_window(7);
let mut window = snapshot.windows[0].clone();
window.title = " editor.rs — Project ".to_owned();
assert_eq!(compact_window_label(&window), "editor.rs — Project");
window.title = " ".to_owned();
assert_eq!(compact_window_label(&window), display_app_name(&window));
fn workspace_selector_has_distinct_active_paint() {
let theme = ShellTheme::default();
let active = workspace_selector_style(true, false, theme);
let occupied = workspace_selector_style(false, true, theme);
let inactive = workspace_selector_style(false, false, theme);
assert!(active.background.is_some());
assert_eq!(
active.background,
Some(Background::Color(color(theme.accent)))
);
assert!(occupied.background.is_some());
assert!(inactive.background.is_none());
assert_eq!(inactive.border.width, 1.0);
}
#[test]
fn window_list_labels_truncate_unicode_without_breaking_characters() {
let snapshot = snapshot_with_fullscreen_window(7);
let mut window = snapshot.windows[0].clone();
window.title = "界".repeat(25);
assert_eq!(
compact_window_label(&window),
format!("{}…", "界".repeat(24))
);
window.title = "界".repeat(24);
assert_eq!(compact_window_label(&window), window.title);
fn workspace_selector_is_output_local() {
assert!(workspace_on_bar(Some(1), Some(1)));
assert!(!workspace_on_bar(Some(2), Some(1)));
assert!(!workspace_on_bar(None, Some(1)));
assert!(!workspace_on_bar(None, None));
}
#[test]
+437
View File
@@ -6,6 +6,322 @@ use cosmic::{Element, Theme};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>;
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default)]
pub(crate) struct Settings {
enabled: bool,
reduced_motion: bool,
speed: f64,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
}
}
}
impl Settings {
fn hover_duration(self) -> std::time::Duration {
self.duration(120.0)
}
pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration {
if !self.enabled || self.reduced_motion {
return std::time::Duration::ZERO;
}
let speed = if self.speed.is_finite() && self.speed > 0.0 {
self.speed.clamp(0.01, 100.0)
} else {
1.0
};
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / speed)
}
}
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
pub(crate) fn configure(settings: Settings) {
*SETTINGS
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
.write()
.unwrap() = settings;
}
pub(crate) struct PopupMotion {
start: f32,
target: f32,
started: Option<std::time::Instant>,
settings: Settings,
}
impl PopupMotion {
pub(crate) fn update_settings(&mut self, settings: Settings) {
let now = std::time::Instant::now();
self.start = self.progress_at(now);
self.settings = settings;
self.started = Some(now);
}
pub(crate) fn new(settings: Settings) -> Self {
Self {
start: 0.0,
target: 1.0,
started: None,
settings,
}
}
pub(crate) fn begin(&mut self, now: std::time::Instant) {
if self.started.is_none() {
self.started = Some(now);
}
}
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
let duration = self
.settings
.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
if duration.is_zero() {
return self.target;
}
let Some(started) = self.started else {
return self.start;
};
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32())
.min(1.0);
if t == 1.0 {
return self.target;
}
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t)
}
pub(crate) fn progress(&self) -> f32 {
self.progress_at(std::time::Instant::now())
}
pub(crate) fn closing(&self) -> bool {
self.target == 0.0
}
pub(crate) fn animating(&self) -> bool {
self.progress() != self.target
}
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
self.start = self.progress_at(now);
self.target = target;
self.started = Some(now);
}
}
/// Animate paint only; the original button retains its layout, hit target,
/// keyboard handling, pressed state, and accessibility identity.
pub(crate) fn button<'a, M: Clone + 'a>(
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
selected: bool,
opacity: f32,
) -> Element<'a, M> {
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
let active = progress.clone();
let hovered = progress.clone();
let paint = move |progress: f32, pressed: bool| cosmic::widget::button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: 6.0.into(),
background: Some(cosmic::iced::Background::Color(cosmic::iced::Color {
a: if pressed {
0.20 * opacity
} else {
((if selected { 0.14 } else { 0.0 })
+ progress * if selected { 0.02 } else { 0.08 })
* opacity
},
..foreground
})),
..Default::default()
};
let content = button
.class(cosmic::theme::Button::Custom {
active: Box::new(move |_, _| paint(active.get(), false)),
hovered: Box::new(move |_, _| paint(hovered.get(), false)),
pressed: Box::new(move |_, _| paint(1.0, true)),
disabled: Box::new(move |_| paint(0.0, false)),
})
.into();
Element::new(Hover {
content,
progress,
duration: SETTINGS
.get()
.map_or_else(Settings::default, |settings| *settings.read().unwrap())
.hover_duration(),
})
}
#[derive(Default)]
struct HoverState {
current: f32,
start: f32,
target: f32,
started: Option<std::time::Instant>,
}
impl HoverState {
fn advance(
&mut self,
target: f32,
now: std::time::Instant,
duration: std::time::Duration,
) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
self.started = Some(now);
}
let t = if duration.is_zero() {
1.0
} else {
self.started.map_or(1.0, |start| {
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
.min(1.0)
})
};
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
if t == 1.0 {
self.current = target;
self.started = None;
}
self.current != target
}
}
struct Hover<'a, M> {
content: Element<'a, M>,
progress: std::rc::Rc<std::cell::Cell<f32>>,
duration: std::time::Duration,
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<HoverState>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(HoverState::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let now = std::time::Instant::now();
let hovered = cursor.is_over(layout.bounds()) && cursor.is_over(*viewport);
let state = tree.state.downcast_mut::<HoverState>();
if state.advance(if hovered { 1.0 } else { 0.0 }, now, self.duration) {
shell.request_redraw_at(now + std::time::Duration::from_millis(16));
}
self.progress.set(state.current);
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.progress
.set(tree.state.downcast_ref::<HoverState>().current);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
pub fn animated<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
@@ -181,6 +497,127 @@ impl widget::Operation for CollectRegions {
mod tests {
use super::*;
#[test]
fn popup_waits_for_configuration_then_opens_and_closes_faster() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
assert_eq!(
motion.progress_at(now + std::time::Duration::from_secs(30)),
0.0
);
motion.begin(now);
// Later configure/focus notifications must not restart the clock.
motion.begin(now + std::time::Duration::from_millis(90));
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(200)),
1.0
);
motion.retarget(0.0, now + std::time::Duration::from_millis(200));
assert!(motion.closing());
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(340)),
0.0
);
}
#[test]
fn popup_reversal_and_motion_policy_are_consistent() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings {
speed: 0.75,
..Settings::default()
});
motion.begin(now);
let reverse = now + std::time::Duration::from_millis(120);
let before = motion.progress_at(reverse);
motion.retarget(0.0, reverse);
assert_eq!(motion.progress_at(reverse), before);
let reopen = reverse + std::time::Duration::from_millis(60);
let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen);
assert_eq!(motion.progress_at(reopen), before);
assert_eq!(
motion.progress_at(reopen + std::time::Duration::from_secs(1)),
1.0
);
for settings in [
Settings {
reduced_motion: true,
..Settings::default()
},
Settings {
enabled: false,
..Settings::default()
},
] {
let mut motion = PopupMotion::new(settings);
assert_eq!(motion.progress_at(now), 1.0);
motion.retarget(0.0, now);
assert_eq!(motion.progress_at(now), 0.0);
}
}
#[test]
fn hover_fades_settle_and_idle_does_not_schedule_frames() {
let now = std::time::Instant::now();
let duration = std::time::Duration::from_millis(120);
let mut hover = HoverState::default();
assert!(!hover.advance(0.0, now, duration));
assert!(hover.advance(1.0, now + std::time::Duration::from_secs(10), duration));
assert_eq!(hover.current, 0.0);
let start = now + std::time::Duration::from_secs(10);
assert!(hover.advance(1.0, start + duration / 2, duration));
assert!((hover.current - 0.5).abs() < 0.00001);
assert!(!hover.advance(1.0, start + duration, duration));
assert_eq!(hover.current, 1.0);
assert!(!hover.advance(1.0, start + duration * 10, duration));
assert!(hover.advance(0.0, start + duration * 11, duration));
assert!(!hover.advance(0.0, start + duration * 12, duration));
assert_eq!(hover.current, 0.0);
}
#[test]
fn hover_reversals_preserve_current_paint() {
let now = std::time::Instant::now();
let duration = std::time::Duration::from_millis(120);
let mut hover = HoverState::default();
hover.advance(1.0, now, duration);
hover.advance(1.0, now + duration / 2, duration);
let before = hover.current;
assert!(hover.advance(0.0, now + duration / 2, duration));
assert_eq!(hover.current, before);
hover.advance(0.0, now + duration, duration);
assert!(hover.current > 0.0 && hover.current < before);
}
#[test]
fn hover_obeys_global_motion_policy() {
assert_eq!(
Settings {
speed: 0.75,
..Settings::default()
}
.hover_duration(),
std::time::Duration::from_millis(160)
);
for settings in [
Settings {
enabled: false,
..Settings::default()
},
Settings {
reduced_motion: true,
..Settings::default()
},
] {
assert!(settings.hover_duration().is_zero());
let mut hover = HoverState::default();
assert!(!hover.advance(1.0, std::time::Instant::now(), settings.hover_duration()));
assert_eq!(hover.current, 1.0);
}
}
#[test]
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
let mut collector = CollectRegions(Vec::new());
let id = widget::Id::new("ferese-blur-card");
+8
View File
@@ -76,12 +76,15 @@ pub struct Update {
pub error: Option<String>,
}
pub struct Service {
settings: Arc<Mutex<Option<Vec<String>>>>,
tx: SyncSender<(u64, Action)>,
rx: Receiver<Update>,
pub generation: u64,
}
impl Service {
pub fn start(settings: Option<Vec<String>>) -> Self {
let live_settings = Arc::new(Mutex::new(settings));
let settings = live_settings.clone();
let (tx, commands) = mpsc::sync_channel::<(u64, Action)>(64);
let (updates, rx) = mpsc::sync_channel(1);
// Polling and writes have separate workers: a missing D-Bus service must
@@ -132,6 +135,7 @@ impl Service {
state.0 = id;
state.1 = true;
}
let settings = settings.lock().unwrap().clone();
let error = execute(&action, settings.as_deref()).err();
let mut state = shared.0.lock().unwrap();
state.1 = false;
@@ -143,6 +147,7 @@ impl Service {
shared.1.notify_one();
});
Self {
settings: live_settings,
tx,
rx,
generation: 0,
@@ -156,6 +161,9 @@ impl Service {
self.generation = generation;
Ok(())
}
pub fn update_settings(&self, settings: Option<Vec<String>>) {
*self.settings.lock().unwrap() = settings;
}
pub fn poll(&self) -> Option<Update> {
self.rx.try_iter().last()
}
+70 -49
View File
@@ -5,7 +5,6 @@ use cosmic::widget::{column, slider};
use status::{Action, Snapshot};
const DEVICE_LIST_HEIGHT: f32 = 180.0;
const OPEN_MS: f32 = 160.0;
fn device_list_height(count: usize) -> Option<f32> {
(count > 4).then_some(DEVICE_LIST_HEIGHT)
@@ -48,30 +47,30 @@ impl Menu {
pub struct OpenMenu {
pub id: window::Id,
pub kind: Menu,
opened: Instant,
pub motion: super::motion::PopupMotion,
pub confirm: Option<Action>,
pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions,
}
impl OpenMenu {
pub fn progress(&self) -> f32 {
let ease = |t: f32| {
let t = t.clamp(0.0, 1.0);
1.0 - (1.0 - t).powi(3)
};
ease(self.opened.elapsed().as_secs_f32() * 1000.0 / OPEN_MS)
self.motion.progress()
}
pub fn animating(&self) -> bool {
self.progress() < 1.0
self.motion.animating()
}
}
impl FereseShell {
pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle<i32>) -> Task<Message> {
if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) {
// Close atomically. Per-widget alpha cannot fade SVG/text caches
// as one surface and leaves bright fragments during the fade.
return self.destroy_menu();
if let Some(menu) = &mut self.menu {
if menu.motion.closing() {
menu.motion.retarget(1.0, Instant::now());
return Task::none();
}
}
return self.close_menu();
}
let destroy = self.destroy_menu();
if !kind.available(&self.status) {
@@ -81,7 +80,7 @@ impl FereseShell {
self.menu = Some(OpenMenu {
id,
kind,
opened: Instant::now(),
motion: super::motion::PopupMotion::new(self.config.animations),
confirm: None,
effects: None,
regions: Default::default(),
@@ -124,6 +123,36 @@ impl FereseShell {
))
})
}
pub fn close_menu(&mut self) -> Task<Message> {
let Some(menu) = &mut self.menu else {
return Task::none();
};
if menu.motion.closing() {
return Task::none();
}
if menu
.effects
.as_ref()
.is_some_and(|effects| effects.surface.version() >= 3)
{
menu.motion.retarget(0.0, Instant::now());
if menu.animating() {
return Task::none();
}
}
self.destroy_menu()
}
pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu {
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
if menu.motion.closing() && !menu.animating() {
return self.destroy_menu();
}
}
Task::none()
}
pub fn optimistic_status(&mut self, action: &Action) {
match action {
Action::Volume(v) => {
@@ -221,7 +250,6 @@ impl FereseShell {
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
])
.height(metrics.height)
.class(button_style(foreground, selected, 1.0))
.on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::OpenMenu(
kind,
@@ -233,7 +261,7 @@ impl FereseShell {
},
))
});
controls = controls.push(control);
controls = controls.push(motion::button(control, foreground, selected, 1.0));
}
controls.into()
}
@@ -243,7 +271,8 @@ impl FereseShell {
return text("").into();
};
let theme = self.config.theme;
let p = menu.progress();
// The compositor fades the complete surface, not individual caches.
let p = 1.0;
let kind = menu.kind;
let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, p);
@@ -615,7 +644,7 @@ impl FereseShell {
}));
// Keep the outer surface transparent; only inner cards paint a background.
cosmic::widget::autosize::autosize(
super::motion::animated(panel.into(), p, menu.regions.clone()),
super::motion::animated(panel.into(), menu.progress(), menu.regions.clone()),
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
)
.limits(
@@ -661,12 +690,15 @@ fn status_summary<'a>(
..Default::default()
}));
let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle {
button::custom(badge)
.padding(0)
.name(if enabled { "Turn off" } else { "Turn on" })
.class(button_style(badge_color, false, opacity))
.on_press(cosmic::Action::App(Message::Control(action)))
.into()
motion::button(
button::custom(badge)
.padding(0)
.name(if enabled { "Turn off" } else { "Turn on" })
.on_press(cosmic::Action::App(Message::Control(action))),
badge_color,
false,
opacity,
)
} else {
badge.into()
};
@@ -800,13 +832,15 @@ fn connection_control<'a>(
.padding(4)
.into();
};
container(
container(motion::button(
button::custom(content)
.width(Length::FillPortion(1))
.padding(4)
.class(button_style(foreground, false, opacity))
.on_press(cosmic::Action::App(Message::Control(action))),
)
foreground,
false,
opacity,
))
.id("ferese-blur-card")
.width(Length::FillPortion(1))
.into()
@@ -818,11 +852,14 @@ fn menu_button<'a>(
foreground: Color,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
button::custom(text(label).size(13))
.padding([6, 8])
.class(button_style(foreground, false, opacity))
.on_press(cosmic::Action::App(message))
.into()
motion::button(
button::custom(text(label).size(13))
.padding([6, 8])
.on_press(cosmic::Action::App(message)),
foreground,
false,
opacity,
)
}
fn toggle_row<'a>(
label: &'a str,
@@ -843,24 +880,6 @@ fn toggle_row<'a>(
.align_y(Alignment::Center)
.into()
}
pub(super) fn button_style(foreground: Color, selected: bool, opacity: f32) -> theme::Button {
let style = move |strength: f32| button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: 6.0.into(),
background: Some(Background::Color(Color {
a: strength * opacity,
..foreground
})),
..Default::default()
};
theme::Button::Custom {
active: Box::new(move |_, _| style(if selected { 0.14 } else { 0.0 })),
hovered: Box::new(move |_, _| style(if selected { 0.16 } else { 0.08 })),
pressed: Box::new(move |_, _| style(0.20)),
disabled: Box::new(move |_| style(0.0)),
}
}
fn slider_row(
source: &'static [u8],
@@ -1071,10 +1090,12 @@ mod tests {
#[test]
fn completed_open_stops_requesting_animation_ticks() {
let past = Instant::now() - Duration::from_secs(1);
let mut motion = super::motion::PopupMotion::new(Default::default());
motion.begin(past);
let menu = OpenMenu {
id: window::Id::unique(),
kind: Menu::Network,
opened: past,
motion,
confirm: None,
effects: None,
regions: Default::default(),