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:
1 parent
b422448d88
commit
672b4e2b60
41 files changed
+2694
-298
No files matched your search
@@ -11,6 +11,7 @@ jiff.workspace = true
|
||||
# Keep GPU rendering available as a fallback/override. The shell defaults to
|
||||
# tiny-skia to avoid a second GPU initialization during desktop startup.
|
||||
libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
|
||||
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
wayland-client.workspace = true
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(®ions) {
|
||||
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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user