Format Rust sources at 100 columns

This commit is contained in:
Samuel Onoja committed 2026-10-22 01:28:03 +01:00
1 parent 4661f595f8
commit 610478f8c7
336 files changed
+23497 -7892

No files matched your search

+231 -110
View File
@@ -18,7 +18,12 @@ use std::time::Duration;
// Expanded lists retain result rows under DND. Compact selection applies DND
// separately, so rendered rows and keyboard focus always share this projection.
fn list_entries<'a>(snapshot: &'a Snapshot, native: &'a [Activity], now: u64, output: &str) -> Vec<&'a Activity> {
fn list_entries<'a>(
snapshot: &'a Snapshot,
native: &'a [Activity],
now: u64,
output: &str,
) -> Vec<&'a Activity> {
ranked(
snapshot.activities.iter().chain(native.iter()),
now,
@@ -87,7 +92,10 @@ struct FocusedActivity {
focused: Option<Id>,
}
impl cosmic::iced::advanced::widget::Operation<Option<Id>> for FocusedActivity {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<Id>>)) {
fn traverse(
&mut self,
operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<Id>>),
) {
operate(self);
}
fn focusable(
@@ -124,13 +132,17 @@ fn icon(name: &str) -> &'static [u8] {
}
fn progress_percent(activity: &Activity) -> Option<u8> {
match activity.data.value {
Value::Progress(value) if activity.state == State::Running => Some((value.clamp(0., 1.) * 100.).round() as u8),
Value::Progress(value) if activity.state == State::Running => {
Some((value.clamp(0., 1.) * 100.).round() as u8)
}
_ => None,
}
}
fn detail(activity: &Activity) -> Option<String> {
progress_percent(activity).is_none().then(|| label(activity))
progress_percent(activity)
.is_none()
.then(|| label(activity))
}
fn label(activity: &Activity) -> String {
@@ -147,7 +159,12 @@ fn label(activity: &Activity) -> String {
Value::Text(text) => text.clone(),
Value::Progress(progress) => format!("{}%", (progress * 100.).round() as u32),
Value::Countdown { .. } | Value::PausedCountdown { .. } => {
let seconds = activity.data.value.remaining_ms(now_ms()).unwrap_or(0).div_ceil(1_000);
let seconds = activity
.data
.value
.remaining_ms(now_ms())
.unwrap_or(0)
.div_ceil(1_000);
format!("{}:{:02}", seconds / 60, seconds % 60)
}
Value::Indeterminate => {
@@ -293,17 +310,28 @@ impl FereseShell {
.map(|output| output.name.as_str())
.unwrap_or("")
.to_owned();
let entries = list_entries(&self.activities.snapshot, &self.activities.native, now, &name);
let entries = list_entries(
&self.activities.snapshot,
&self.activities.native,
now,
&name,
);
let compact: Vec<_> = entries
.iter()
.copied()
.filter(|entry| entry.eligible(now, dnd) && entry.data.source != ferese_activities::Source::Media)
.filter(|entry| {
entry.eligible(now, dnd)
&& entry.data.source != ferese_activities::Source::Media
})
.collect();
let presentation = self.activities.flat.entry(output.bar).or_default();
presentation
.selection
.select(&compact, self.activities.snapshot.limits, dnd, 0);
let rows = entries.iter().map(|entry| entry.id.clone()).collect::<Vec<_>>();
let rows = entries
.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<_>>();
reconcile_focus(&mut presentation.focus, &presentation.rows, &rows);
presentation.rows = rows;
continue;
@@ -313,7 +341,12 @@ impl FereseShell {
.output_for_bar(output.bar)
.map(|output| output.name.as_str())
.unwrap_or("");
let entries = list_entries(&self.activities.snapshot, &self.activities.native, now, name);
let entries = list_entries(
&self.activities.snapshot,
&self.activities.native,
now,
name,
);
let workspace_width = self.hub_workspace_width(output.bar) as f32;
let panel = self.panel_for_bar(output.bar);
let available = super::panel_hub::content_width(panel, output.size) as f32
@@ -328,16 +361,22 @@ impl FereseShell {
let compact: Vec<_> = entries
.iter()
.copied()
.filter(|entry| entry.eligible(now, dnd) && entry.data.source != ferese_activities::Source::Media)
.filter(|entry| {
entry.eligible(now, dnd)
&& entry.data.source != ferese_activities::Source::Media
})
.collect();
let compact = if self.compact_activities_configured(output.bar) {
compact
} else {
Vec::new()
};
presentation
.selection
.select(&compact, self.activities.snapshot.limits, dnd, protected);
presentation.selection.select(
&compact,
self.activities.snapshot.limits,
dnd,
protected,
);
let rows: Vec<_> = entries.iter().map(|entry| entry.id.clone()).collect();
reconcile_focus(&mut presentation.focus, &presentation.rows, &rows);
presentation.rows = rows;
@@ -355,11 +394,14 @@ impl FereseShell {
.unwrap_or(now)
.saturating_sub(entry.result_flash_ms),
);
presentation.tints.entry(entry.id.clone()).or_insert_with(|| {
let mut value = AnimatedValue::new(if age < 400 { 1. } else { 0. });
value.set_target(0.);
value
});
presentation
.tints
.entry(entry.id.clone())
.or_insert_with(|| {
let mut value = AnimatedValue::new(if age < 400 { 1. } else { 0. });
value.set_target(0.);
value
});
}
}
let target = f64::from(presentation.selection.visible.len() == 2);
@@ -367,9 +409,12 @@ impl FereseShell {
.divider
.get_or_insert_with(|| AnimatedValue::new(0.))
.retarget_preserving_motion(target);
presentation
.compact
.update(&entries, &presentation.selection, self.render.font, available);
presentation.compact.update(
&entries,
&presentation.selection,
self.render.font,
available,
);
let compact_width = presentation.compact.width;
if !presentation.selection.visible.is_empty() {
runtime.idle_content_width = Some(f64::from(compact_width));
@@ -402,29 +447,35 @@ impl FereseShell {
{
return false;
}
self.outputs.iter().filter(|output| !output.hidden).any(|output| {
let list = self
.menu
.as_ref()
.is_some_and(|menu| menu.kind == status_ui::Menu::Activities && menu.anchor.parent == output.bar);
let routed = self
.output_for_bar(output.bar)
.is_some_and(|output| activity.data.output.as_deref() == Some(&output.name));
if let Some(runtime) = &output.runtime.hub {
let runtime = runtime.borrow();
routed
&& (runtime.activities.selection.visible.contains(&activity.id)
|| runtime.hub.mode()
== ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Activities))
} else {
routed
&& (list
|| self.activities.flat.get(&output.bar).is_some_and(|presentation| {
self.compact_activities_configured(output.bar)
&& presentation.selection.visible.contains(&activity.id)
}))
}
})
self.outputs
.iter()
.filter(|output| !output.hidden)
.any(|output| {
let list = self.menu.as_ref().is_some_and(|menu| {
menu.kind == status_ui::Menu::Activities && menu.anchor.parent == output.bar
});
let routed = self.output_for_bar(output.bar).is_some_and(|output| {
activity.data.output.as_deref() == Some(&output.name)
});
if let Some(runtime) = &output.runtime.hub {
let runtime = runtime.borrow();
routed
&& (runtime.activities.selection.visible.contains(&activity.id)
|| runtime.hub.mode()
== ferese_panel::morph::Mode::Expanded(
ferese_panel::morph::Content::Activities,
))
} else {
routed
&& (list
|| self.activities.flat.get(&output.bar).is_some_and(
|presentation| {
self.compact_activities_configured(output.bar)
&& presentation.selection.visible.contains(&activity.id)
},
))
}
})
});
if visible {
cosmic::iced::time::every(Duration::from_secs(1)).map(|_| ())
@@ -456,20 +507,24 @@ impl FereseShell {
reduced,
ring_color,
);
let center: Element<'_, cosmic::Action<Message>> = if let Some(percent) = progress_percent(activity) {
self.render
.text(percent.to_string())
.size(if percent == 100 { 9. } else { 10. })
.line_height(cosmic::iced::widget::text::LineHeight::Relative(1.))
.class(theme::Text::Color(color))
.into()
} else {
icons::tinted(icon(&activity.data.icon), 14, color).into()
};
let center: Element<'_, cosmic::Action<Message>> =
if let Some(percent) = progress_percent(activity) {
self.render
.text(percent.to_string())
.size(if percent == 100 { 9. } else { 10. })
.line_height(cosmic::iced::widget::text::LineHeight::Relative(1.))
.class(theme::Text::Color(color))
.into()
} else {
icons::tinted(icon(&activity.data.icon), 14, color).into()
};
cosmic::iced::widget::stack![ring, container(center).center_x(26).center_y(26)].into()
}
pub fn activity_available(&self, id: window::Id) -> bool {
let name = self.output_for_bar(id).map(|output| output.name.as_str()).unwrap_or("");
let name = self
.output_for_bar(id)
.map(|output| output.name.as_str())
.unwrap_or("");
ranked(
self.activities
.snapshot
@@ -537,16 +592,17 @@ impl FereseShell {
return (container(self.render.text("")).width(0).into(), false);
};
let palette = self.config.theme.palette();
let result_age = activity
.result_deadline
.map(|deadline| now_ms().saturating_sub(deadline.saturating_sub(activity.result_flash_ms)));
let foreground = if result_age.is_some_and(|age| age < 400) && activity.state == State::Failed {
palette.error
} else if result_age.is_some_and(|age| age < 400) && activity.state == State::Done {
palette.accent
} else {
palette.text
};
let result_age = activity.result_deadline.map(|deadline| {
now_ms().saturating_sub(deadline.saturating_sub(activity.result_flash_ms))
});
let foreground =
if result_age.is_some_and(|age| age < 400) && activity.state == State::Failed {
palette.error
} else if result_age.is_some_and(|age| age < 400) && activity.state == State::Done {
palette.accent
} else {
palette.text
};
let mut content = row![self.activity_icon(activity, foreground, width.is_some())]
.spacing(6)
.align_y(Alignment::Center);
@@ -573,7 +629,8 @@ impl FereseShell {
)),
);
}
let hidden = presentation.selection.hidden + presentation.selection.visible.len().saturating_sub(1);
let hidden =
presentation.selection.hidden + presentation.selection.visible.len().saturating_sub(1);
if hidden > 0 && !self.notifications.dnd && self.activities.snapshot.limits.show_badge {
content = content.push(self.render.text(format!("+{hidden}")).size(11));
}
@@ -613,10 +670,16 @@ impl FereseShell {
))
});
(
cosmic::iced::widget::mouse_area(crate::motion::button(self.render, button, palette.text, selected, 1.))
.on_enter(cosmic::Action::App(Message::ActivityHover(id, true)))
.on_exit(cosmic::Action::App(Message::ActivityHover(id, false)))
.into(),
cosmic::iced::widget::mouse_area(crate::motion::button(
self.render,
button,
palette.text,
selected,
1.,
))
.on_enter(cosmic::Action::App(Message::ActivityHover(id, true)))
.on_exit(cosmic::Action::App(Message::ActivityHover(id, false)))
.into(),
true,
)
}
@@ -711,7 +774,13 @@ impl FereseShell {
id: window::Id,
) -> Option<impl cosmic::iced::advanced::widget::Operation<Option<Id>> + use<>> {
let bar = self.activity_list_bar(id)?;
let hub = self.outputs.iter().find(|entry| entry.bar == bar)?.runtime.hub.as_ref();
let hub = self
.outputs
.iter()
.find(|entry| entry.bar == bar)?
.runtime
.hub
.as_ref();
let rows = if let Some(hub) = hub {
hub.borrow().activities.rows.clone()
} else {
@@ -729,13 +798,21 @@ impl FereseShell {
key: Option<cosmic::iced::keyboard::key::Named>,
) -> Option<Task<Message>> {
Some(
cosmic::iced::advanced::widget::operate(self.activity_focus_query(id)?)
.map(move |focused| cosmic::Action::App(Message::ActivityNavigation(id, focused, key))),
cosmic::iced::advanced::widget::operate(self.activity_focus_query(id)?).map(
move |focused| cosmic::Action::App(Message::ActivityNavigation(id, focused, key)),
),
)
}
pub fn activity_key(&self, id: window::Id, key: cosmic::iced::keyboard::key::Named) -> Option<Task<Message>> {
pub fn activity_key(
&self,
id: window::Id,
key: cosmic::iced::keyboard::key::Named,
) -> Option<Task<Message>> {
use cosmic::iced::keyboard::key::Named;
if !matches!(key, Named::ArrowUp | Named::ArrowDown | Named::Home | Named::End) {
if !matches!(
key,
Named::ArrowUp | Named::ArrowDown | Named::Home | Named::End
) {
return None;
}
self.query_activity_focus(id, Some(key))
@@ -747,7 +824,13 @@ impl FereseShell {
key: Option<cosmic::iced::keyboard::key::Named>,
) -> Option<Task<Message>> {
let bar = self.activity_list_bar(id)?;
let hub = self.outputs.iter().find(|entry| entry.bar == bar)?.runtime.hub.as_ref();
let hub = self
.outputs
.iter()
.find(|entry| entry.bar == bar)?
.runtime
.hub
.as_ref();
let mut runtime = hub.map(|hub| hub.borrow_mut());
let presentation = if let Some(runtime) = &mut runtime {
&mut runtime.activities
@@ -773,9 +856,14 @@ impl FereseShell {
_ => presentation.rows.len().saturating_sub(1),
};
presentation.focus = presentation.rows.get(next).cloned();
Some(presentation.focus.as_ref().map_or_else(Task::none, |focus| {
cosmic::widget::button::focus(activity_widget_id(bar, focus))
}))
Some(
presentation
.focus
.as_ref()
.map_or_else(Task::none, |focus| {
cosmic::widget::button::focus(activity_widget_id(bar, focus))
}),
)
}
pub fn activity_result_tint(&self, id: window::Id) -> Option<(cosmic::iced::Color, f32)> {
let runtime = self
@@ -787,23 +875,28 @@ impl FereseShell {
.as_ref()?
.borrow();
let palette = self.config.theme.palette();
runtime.activities.selection.visible.iter().find_map(|identity| {
let entry = self
.activities
.snapshot
.activities
.iter()
.find(|entry| &entry.id == identity)?;
let alpha = runtime.activities.tints.get(identity)?.current as f32;
(alpha > 0.).then_some((
if entry.state == State::Failed {
palette.error
} else {
palette.accent
},
alpha.clamp(0., 1.) * 0.4,
))
})
runtime
.activities
.selection
.visible
.iter()
.find_map(|identity| {
let entry = self
.activities
.snapshot
.activities
.iter()
.find(|entry| &entry.id == identity)?;
let alpha = runtime.activities.tints.get(identity)?.current as f32;
(alpha > 0.).then_some((
if entry.state == State::Failed {
palette.error
} else {
palette.accent
},
alpha.clamp(0., 1.) * 0.4,
))
})
}
pub fn activity_compact(
&self,
@@ -843,12 +936,14 @@ impl FereseShell {
.map_or(1., |value| value.current as f32)
.clamp(0., 1.);
let divider_color = palette.text.scale_alpha(opacity * alpha * 0.25);
strip = strip.push(container(cosmic::iced::widget::Space::new().width(1).height(18)).class(
theme::Container::custom(move |_| container::Style {
background: Some(divider_color.into()),
..Default::default()
}),
));
strip = strip.push(
container(cosmic::iced::widget::Space::new().width(1).height(18)).class(
theme::Container::custom(move |_| container::Style {
background: Some(divider_color.into()),
..Default::default()
}),
),
);
}
let semantic = if activity.state == State::Failed {
palette.error
@@ -936,7 +1031,10 @@ impl FereseShell {
pressed: Box::new(move |_, _| appearance),
disabled: Box::new(move |_| appearance),
})
.on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Activities))),
.on_press(cosmic::Action::App(Message::HubOpen(
id,
status_ui::Menu::Activities,
))),
);
let budget = presentation.compact.width
+ if self.hub_workspace_width(id) > 0. {
@@ -947,13 +1045,29 @@ impl FereseShell {
container(controls)
.padding([0., compact::PADDING])
.height(Length::Fill)
.width(if intrinsic { Length::Fixed(budget) } else { Length::Fill })
.width(if intrinsic {
Length::Fixed(budget)
} else {
Length::Fill
})
.into()
}
pub fn activity_list(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
pub fn activity_list(
&self,
id: window::Id,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let palette = self.config.theme.palette();
let name = self.output_for_bar(id).map(|output| output.name.as_str()).unwrap_or("");
let entries = list_entries(&self.activities.snapshot, &self.activities.native, now_ms(), name);
let name = self
.output_for_bar(id)
.map(|output| output.name.as_str())
.unwrap_or("");
let entries = list_entries(
&self.activities.snapshot,
&self.activities.native,
now_ms(),
name,
);
let focus = self
.outputs
.iter()
@@ -1031,7 +1145,10 @@ impl FereseShell {
Template::Media { artist, playing } => {
body = body.push(
self.render
.text(format!("{artist} · {}", if *playing { "Playing" } else { "Paused" }))
.text(format!(
"{artist} · {}",
if *playing { "Playing" } else { "Paused" }
))
.size(12),
);
}
@@ -1042,7 +1159,8 @@ impl FereseShell {
}
_ => {}
}
if activity.data.source == ferese_activities::Source::Media && activity.id.session == 0 {
if activity.data.source == ferese_activities::Source::Media && activity.id.session == 0
{
body = body.push(
button::custom(icons::tinted(icons::MEDIA_PLAY, 16, palette.text))
.name("Play or pause")
@@ -1085,7 +1203,10 @@ impl FereseShell {
pressed: Box::new(move |focused, _| appearance(0.10, focused)),
disabled: Box::new(move |_| appearance(0.025, false)),
})
.on_press(cosmic::Action::App(Message::ActivityActivate(id, activity.id.clone())))
.on_press(cosmic::Action::App(Message::ActivityActivate(
id,
activity.id.clone()
)))
.width(Length::Fill),
]
.width(Length::Fill)
+23 -5
View File
@@ -19,19 +19,35 @@ pub(super) struct Compact {
}
impl Compact {
pub fn update(&mut self, entries: &[&Activity], selection: &Selection, font: Font, available: f32) {
pub fn update(
&mut self,
entries: &[&Activity],
selection: &Selection,
font: Font,
available: f32,
) {
if self.font != Some(font) {
self.font = Some(font);
self.titles.clear();
self.badge = None;
}
self.titles.retain(|id, _| selection.visible.contains(id));
for entry in entries.iter().filter(|entry| selection.visible.contains(&entry.id)) {
for entry in entries
.iter()
.filter(|entry| selection.visible.contains(&entry.id))
{
let title = &entry.data.title;
if self.titles.get(&entry.id).is_none_or(|(cached, _)| cached != title) {
if self
.titles
.get(&entry.id)
.is_none_or(|(cached, _)| cached != title)
{
self.titles.insert(
entry.id.clone(),
(title.clone(), text_width(title, 12., font).ceil().clamp(48., 100.)),
(
title.clone(),
text_width(title, 12., font).ceil().clamp(48., 100.),
),
);
}
}
@@ -65,7 +81,9 @@ impl Compact {
}
pub fn text_width(&self, id: &Id) -> f32 {
self.titles.get(id).map_or(48., |(_, width)| width * self.text_scale)
self.titles
.get(id)
.map_or(48., |(_, width)| width * self.text_scale)
}
}
+47 -14
View File
@@ -8,14 +8,20 @@ use ferese_ipc::activities::{PomodoroAction as Action, now_ms};
use ferese_theme::{icons, menus};
impl FereseShell {
pub(super) fn pomodoro_controls(&self, bar: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
pub(super) fn pomodoro_controls(
&self,
bar: window::Id,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let palette = self.config.theme;
let primary = color_with_opacity(palette.text_primary, opacity);
let muted = color_with_opacity(palette.text_muted, opacity);
let accent = color_with_opacity(palette.accent, opacity);
let current = self.activities.snapshot.pomodoro.as_ref();
let session = current.map(|current| current.id.clone());
let settings = current.map_or(&self.config.activities.pomodoro, |current| &current.timer.settings);
let settings = current.map_or(&self.config.activities.pomodoro, |current| {
&current.timer.settings
});
let (phase, seconds, description, label, action, symbol) = match current {
None => (
"Focus timer",
@@ -47,11 +53,17 @@ impl FereseShell {
let interval = if timer.phase == Phase::Work {
format!(
"Interval {} of {}",
timer.completed_work.saturating_add(1).min(settings.intervals),
timer
.completed_work
.saturating_add(1)
.min(settings.intervals),
settings.intervals
)
} else {
format!("{} of {} complete", timer.completed_work, settings.intervals)
format!(
"{} of {} complete",
timer.completed_work, settings.intervals
)
};
format!(
"{}{interval}",
@@ -109,8 +121,14 @@ impl FereseShell {
let heading = row![
badge,
column![
self.render.text("Pomodoro").size(13).class(theme::Text::Color(primary)),
self.render.text(phase).size(11).class(theme::Text::Color(muted)),
self.render
.text("Pomodoro")
.size(13)
.class(theme::Text::Color(primary)),
self.render
.text(phase)
.size(11)
.class(theme::Text::Color(muted)),
]
.spacing(3)
.width(Length::Fill),
@@ -125,9 +143,12 @@ impl FereseShell {
.spacing(8)
.align_y(Alignment::Center);
let primary_button = button::custom(
row![icons::tinted(symbol, 16, accent), self.render.text(label).size(13)]
.spacing(6)
.align_y(Alignment::Center),
row![
icons::tinted(symbol, 16, accent),
self.render.text(label).size(13)
]
.spacing(6)
.align_y(Alignment::Center),
)
.id(session.as_ref().map_or_else(
|| cosmic::widget::Id::new(format!("pomodoro-start-{bar:?}")),
@@ -137,9 +158,15 @@ impl FereseShell {
.padding([7, 10])
.width(Length::Fill)
.on_press(message(action));
let mut controls = row![motion::button(self.render, primary_button, accent, true, opacity)]
.spacing(4)
.align_y(Alignment::Center);
let mut controls = row![motion::button(
self.render,
primary_button,
accent,
true,
opacity
)]
.spacing(4)
.align_y(Alignment::Center);
let secondary = |label: &'static str, symbol, action| {
let control = button::custom(icons::tinted(symbol, 16, primary))
.name(label)
@@ -147,7 +174,10 @@ impl FereseShell {
.on_press(message(action));
tooltip(
motion::button(self.render, control, primary, false, opacity),
self.render.text(label).size(12).class(theme::Text::Color(primary)),
self.render
.text(label)
.size(12)
.class(theme::Text::Color(primary)),
cosmic::widget::tooltip::Position::Top,
)
};
@@ -171,7 +201,10 @@ impl FereseShell {
menus::section(
column![
heading,
self.render.text(description).size(11).class(theme::Text::Color(muted)),
self.render
.text(description)
.size(11)
.class(theme::Text::Color(muted)),
controls,
]
.spacing(10)
+22 -7
View File
@@ -45,14 +45,21 @@ impl Default for RingState {
}
}
impl RingState {
fn advance(&mut self, progress: Option<f32>, animate: bool, reduced: bool, now: Instant) -> bool {
let elapsed = self
.sampled
.map_or(Duration::ZERO, |before| now.saturating_duration_since(before));
fn advance(
&mut self,
progress: Option<f32>,
animate: bool,
reduced: bool,
now: Instant,
) -> bool {
let elapsed = self.sampled.map_or(Duration::ZERO, |before| {
now.saturating_duration_since(before)
});
let initial = self.sampled.is_none();
self.sampled = Some(now);
self.elapsed = self.elapsed.saturating_add(elapsed);
self.value.retarget_preserving_motion(f64::from(progress.unwrap_or(0.)));
self.value
.retarget_preserving_motion(f64::from(progress.unwrap_or(0.)));
if initial || reduced || !animate {
self.value.snap();
} else {
@@ -114,7 +121,10 @@ impl canvas::Program<cosmic::Action<Message>, Theme, cosmic::Renderer> for Ring
.clamp(0., 1.),
)
} else if self.animate && !self.reduced {
(state.elapsed.as_secs_f32() / 3. * std::f32::consts::TAU, 0.23)
(
state.elapsed.as_secs_f32() / 3. * std::f32::consts::TAU,
0.23,
)
} else {
(0., 0.)
};
@@ -151,7 +161,12 @@ mod tests {
state.value.retarget_preserving_motion(0.9);
assert_eq!(state.value.velocity, velocity);
for frame in 1..=200 {
state.advance(Some(0.9), true, false, now + Duration::from_millis(50 + frame * 16));
state.advance(
Some(0.9),
true,
false,
now + Duration::from_millis(50 + frame * 16),
);
}
assert!(!state.value.is_animating());
assert!(!state.advance(None, false, false, now + Duration::from_secs(5)));
+240 -72
View File
@@ -1,6 +1,6 @@
use super::{
BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, alignment, bar_icon, button, color,
container, control, motion, row, status_ui, theme, window,
BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, alignment, bar_icon,
button, color, container, control, motion, row, status_ui, theme, window,
};
use crate::panel::{Availability, Item, ItemKind, Panel, Zone};
#[cfg(test)]
@@ -16,7 +16,10 @@ use crate::panel_layout::Placement;
mod binding_mode_tests;
impl FereseShell {
pub(super) fn binding_mode_transition(&mut self, was_active: bool) -> cosmic::app::Task<Message> {
pub(super) fn binding_mode_transition(
&mut self,
was_active: bool,
) -> cosmic::app::Task<Message> {
match (was_active, self.snapshot.binding_mode.is_some()) {
(false, true) => {
let mut tasks = vec![self.destroy_menu(), self.dismiss_workspace_tooltip()];
@@ -39,25 +42,35 @@ impl FereseShell {
}
pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> {
let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?;
self.snapshot.outputs.iter().find(|output| output.name == name)
let name = self
.outputs
.iter()
.find(|entry| entry.bar == id)?
.name
.as_deref()?;
self.snapshot
.outputs
.iter()
.find(|output| output.name == name)
}
pub(super) fn active_bar(&self) -> Option<window::Id> {
self.outputs
.iter()
.find(|entry| {
self.snapshot
.outputs
.iter()
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
self.snapshot.outputs.iter().any(|output| {
output.focused && Some(output.name.as_str()) == entry.name.as_deref()
})
})
.or_else(|| self.outputs.first())
.map(|output| output.bar)
}
pub(super) fn bar_hidden(&self, id: window::Id) -> bool {
output_bar_hidden(&self.snapshot, self.output_for_bar(id).map(|output| output.id))
output_bar_hidden(
&self.snapshot,
self.output_for_bar(id).map(|output| output.id),
)
}
pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
@@ -75,7 +88,8 @@ impl FereseShell {
return self.view_hub(id);
}
let mut shell_theme = self.config.theme.for_bar();
shell_theme.bar_background[3] = (shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8;
shell_theme.bar_background[3] =
(shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8;
let output = self.outputs.iter().find(|output| output.bar == id);
let effects = output.and_then(|output| output.effects.as_ref());
let compositor_material = effects.is_some();
@@ -91,7 +105,11 @@ impl FereseShell {
let mut samples = Vec::new();
for zone in [&panel.center, &panel.start, &panel.end] {
for group in &zone.groups {
for item in group.items.iter().filter(|item| item.available(availability)) {
for item in group
.items
.iter()
.filter(|item| item.available(availability))
{
if matches!(item.kind, ItemKind::FocusedWindow)
&& focused_bar_title(&self.snapshot, self.output_for_bar(id)).is_empty()
{
@@ -171,7 +189,10 @@ impl FereseShell {
availability,
|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0),
|item, surface| {
let Some(Placement::Visible { representation, width }) = resolution.items.get(&item.id)
let Some(Placement::Visible {
representation,
width,
}) = resolution.items.get(&item.id)
else {
unreachable!()
};
@@ -221,11 +242,17 @@ impl FereseShell {
}
};
cosmic::iced::widget::stack![
row![start, cosmic::iced::widget::Space::new().width(Length::Fill), end]
.width(Length::Fill)
.height(Length::Fill)
.align_y(cosmic::iced::Alignment::Center),
container(center).center_x(Length::Fill).center_y(Length::Fill),
row![
start,
cosmic::iced::widget::Space::new().width(Length::Fill),
end
]
.width(Length::Fill)
.height(Length::Fill)
.align_y(cosmic::iced::Alignment::Center),
container(center)
.center_x(Length::Fill)
.center_y(Length::Fill),
]
.into()
},
@@ -267,9 +294,17 @@ impl FereseShell {
self.with_workspace_hover(id, content)
}
fn view_binding_mode<'a>(&'a self, id: window::Id, name: &'a str) -> Element<'a, cosmic::Action<Message>> {
fn view_binding_mode<'a>(
&'a self,
id: window::Id,
name: &'a str,
) -> Element<'a, cosmic::Action<Message>> {
let panel = self.panel_for_bar(id);
let output = self.outputs.iter().find(|entry| entry.bar == id).expect("panel output");
let output = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.expect("panel output");
let effects = output.effects.as_ref();
let width = output
.runtime
@@ -297,7 +332,9 @@ impl FereseShell {
.width(width as f32)
.height(panel.resolved_geometry().height)
.align_y(alignment::Vertical::Center);
let content = container(badge).center_x(Length::Fill).center_y(Length::Fill);
let content = container(badge)
.center_x(Length::Fill)
.center_y(Length::Fill);
let palette = self.config.theme.for_bar();
let mut background = color(palette.bar_background);
background.a = palette.panel_opacity(panel.background_opacity);
@@ -345,7 +382,12 @@ impl FereseShell {
.map(|(workspace, target)| presentation::HoverTarget {
id: target.clone(),
exited: Box::new(move || {
cosmic::Action::App(Message::HoverWorkspace(id, workspace, target.clone(), false))
cosmic::Action::App(Message::HoverWorkspace(
id,
workspace,
target.clone(),
false,
))
}),
}),
)
@@ -377,7 +419,10 @@ impl FereseShell {
ItemKind::Overview => motion::button(
self.render,
button::custom(overview_control(
BarMetrics { control_height, ..bar },
BarMetrics {
control_height,
..bar
},
color(shell_theme.accent),
))
.height(control_height)
@@ -425,15 +470,30 @@ impl FereseShell {
return (container(element).max_width(360).into(), !title.is_empty());
}
ItemKind::Activities => {
return self.view_activity_item(id, item, representation, selected, width, control_height);
return self.view_activity_item(
id,
item,
representation,
selected,
width,
control_height,
);
}
ItemKind::Media => self.view_media_item(representation, selected, width),
ItemKind::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected),
ItemKind::NightLight => self.view_status_item(status_ui::Menu::NightLight, false, selected),
ItemKind::QuickSettings => {
self.view_status_item(status_ui::Menu::System, false, selected)
}
ItemKind::NightLight => {
self.view_status_item(status_ui::Menu::NightLight, false, selected)
}
ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected),
ItemKind::Audio => self.view_status_item(status_ui::Menu::Audio, false, selected),
ItemKind::Recording => self.view_status_item(status_ui::Menu::Recording, false, selected),
ItemKind::Notifications => self.view_status_item(status_ui::Menu::Notifications, false, selected),
ItemKind::Recording => {
self.view_status_item(status_ui::Menu::Recording, false, selected)
}
ItemKind::Notifications => {
self.view_status_item(status_ui::Menu::Notifications, false, selected)
}
ItemKind::Battery { percentage } => self.view_status_item(
status_ui::Menu::Battery,
percentage && representation != Representation::Icon,
@@ -453,7 +513,9 @@ impl FereseShell {
self.render
.text(date)
.size(bar.text_size)
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(20.into()))
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(
20.into(),
))
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
);
@@ -496,7 +558,10 @@ impl FereseShell {
))
.name("Display mode")
.height(control_height)
.padding([0.0, ((bar.height - f32::from(bar.icon_size)) * 0.5).max(4.0)])
.padding([
0.0,
((bar.height - f32::from(bar.icon_size)) * 0.5).max(4.0),
])
.on_press(cosmic::Action::App(Message::OpenDisplays(
self.outputs
.iter()
@@ -515,15 +580,17 @@ impl FereseShell {
.into();
(
element.map(move |action| match action {
cosmic::Action::App(Message::OpenMenu(kind, anchor)) => cosmic::Action::App(Message::OpenPopover(
kind,
status_ui::PopoverAnchor {
parent: id,
panel: panel.clone(),
item: Some(item.clone()),
rectangle: anchor,
},
)),
cosmic::Action::App(Message::OpenMenu(kind, anchor)) => {
cosmic::Action::App(Message::OpenPopover(
kind,
status_ui::PopoverAnchor {
parent: id,
panel: panel.clone(),
item: Some(item.clone()),
rectangle: anchor,
},
))
}
other => other,
}),
true,
@@ -532,10 +599,19 @@ impl FereseShell {
pub(crate) fn view_panel_overflow<'a>(
&'a self,
mut rows: cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>,
mut rows: cosmic::widget::Column<
'a,
cosmic::Action<Message>,
cosmic::Theme,
cosmic::Renderer,
>,
) -> cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer> {
let Some(menu) = &self.menu else { return rows };
let Some(output) = self.outputs.iter().find(|output| output.bar == menu.anchor.parent) else {
let Some(output) = self
.outputs
.iter()
.find(|output| output.bar == menu.anchor.parent)
else {
return rows;
};
let panel = self.panel_for_bar(output.bar);
@@ -569,7 +645,10 @@ impl FereseShell {
) => cosmic::Action::App(Message::PanelAction(Box::new(message))),
other => other,
});
rows = rows.push(cosmic::widget::column![self.render.text(item.kind.label()).size(12), element].spacing(4));
rows = rows.push(
cosmic::widget::column![self.render.text(item.kind.label()).size(12), element]
.spacing(4),
);
}
rows
}
@@ -645,7 +724,9 @@ impl FereseShell {
))
.height(bar.group_item_height)
.padding(0)
.on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id)));
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
workspace.id,
)));
let selector = motion::button(
self.render,
@@ -658,8 +739,10 @@ impl FereseShell {
false,
opacity,
);
let target =
cosmic::iced::advanced::widget::Id::new(format!("workspace:{id:?}:{instance}:{}", workspace.id));
let target = cosmic::iced::advanced::widget::Id::new(format!(
"workspace:{id:?}:{instance}:{}",
workspace.id
));
let selector = cosmic::widget::mouse_area(container(selector).id(target.clone()))
.on_enter(cosmic::Action::App(Message::HoverWorkspace(
id,
@@ -679,7 +762,12 @@ impl FereseShell {
selector
} else {
selector.on_move(move |_| {
cosmic::Action::App(Message::HoverWorkspace(id, workspace.id, target.clone(), true))
cosmic::Action::App(Message::HoverWorkspace(
id,
workspace.id,
target.clone(),
true,
))
})
};
workspace_buttons = workspace_buttons.push(selector);
@@ -688,8 +776,14 @@ impl FereseShell {
}
}
fn group_material_id(panel: &Panel, group: &crate::panel::Group) -> cosmic::iced::advanced::widget::Id {
cosmic::iced::advanced::widget::Id::new(format!("panel:{}:group:{}:material", panel.id.0, group.id.0))
fn group_material_id(
panel: &Panel,
group: &crate::panel::Group,
) -> cosmic::iced::advanced::widget::Id {
cosmic::iced::advanced::widget::Id::new(format!(
"panel:{}:group:{}:material",
panel.id.0, group.id.0
))
}
/// Render ordered instances; item rendering does not choose its neighbors or surface.
@@ -706,7 +800,8 @@ fn view_zone<'a>(
.align_y(cosmic::iced::Alignment::Center);
for group in &zone.groups {
let surface = panel.group_surface();
let mut controls = row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center);
let mut controls =
row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center);
let mut count = 0;
for item in group
.items
@@ -714,11 +809,14 @@ fn view_zone<'a>(
.filter(|item| item.available(availability) && visible(item))
{
if count > 0 {
controls =
controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
controls = controls.push(
cosmic::iced::widget::Space::new()
.width(item.gap_before.unwrap_or(group.spacing)),
);
}
let (element, _) = render(item, surface);
controls = controls.push(container(element).id(format!("panel:{}:item:{}", panel.id.0, item.id.0)));
controls = controls
.push(container(element).id(format!("panel:{}:item:{}", panel.id.0, item.id.0)));
count += 1;
}
if count == 0 {
@@ -756,9 +854,16 @@ pub(super) fn bar_content<'a>(
container(content).center_y(height)
}
pub(super) fn overview_control(bar: BarMetrics, foreground: Color) -> Element<'static, cosmic::Action<Message>> {
pub(super) fn overview_control(
bar: BarMetrics,
foreground: Color,
) -> Element<'static, cosmic::Action<Message>> {
bar_content(
bar_icon(ferese_theme::icons::FERESE, bar.overview_icon_size, foreground),
bar_icon(
ferese_theme::icons::FERESE,
bar.overview_icon_size,
foreground,
),
bar.control_height,
)
.width(bar.overview_icon_size)
@@ -795,7 +900,10 @@ pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -
})
}
pub(super) fn focused_bar_title<'a>(snapshot: &'a ShellSnapshot, output: Option<&control::OutputSnapshot>) -> &'a str {
pub(super) fn focused_bar_title<'a>(
snapshot: &'a ShellSnapshot,
output: Option<&control::OutputSnapshot>,
) -> &'a str {
let Some(output) = output else {
return "";
};
@@ -812,7 +920,10 @@ pub(super) fn focused_bar_title<'a>(snapshot: &'a ShellSnapshot, output: Option<
})
}
pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool {
pub(super) fn workspace_active_on_bar(
workspace: u64,
output: Option<&control::OutputSnapshot>,
) -> bool {
output.is_some_and(|output| output.active_workspace == workspace)
}
@@ -934,7 +1045,13 @@ mod island_tests {
let mut previous_label = 0.;
for width in [120., 200., 320.] {
let mut view = shell
.view_panel_item(shell.outputs[0].bar, &item, Some(width), true, Representation::Wide)
.view_panel_item(
shell.outputs[0].bar,
&item,
Some(width),
true,
Representation::Wide,
)
.0;
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
@@ -1004,12 +1121,20 @@ mod island_tests {
let last = labels.0.last().unwrap();
let right = node.size().width - last.x - last.width;
assert!((left - right).abs() < 0.01);
assert!(right <= 4.01, "panel height must not add horizontal space to the clock");
assert!(
right <= 4.01,
"panel height must not add horizontal space to the clock"
);
}
}
}
fn measure_zone(panel: &Panel, zone: &Zone, availability: Availability, title_content: bool) -> Bounds {
fn measure_zone(
panel: &Panel,
zone: &Zone,
availability: Availability,
title_content: bool,
) -> Bounds {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
@@ -1040,7 +1165,12 @@ mod island_tests {
20.
};
(
container(cosmic::iced::widget::Space::new().width(width).height(height)).into(),
container(
cosmic::iced::widget::Space::new()
.width(width)
.height(height),
)
.into(),
!matches!(item.kind, ItemKind::FocusedWindow) || title_content,
)
},
@@ -1053,7 +1183,10 @@ mod island_tests {
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1200., panel.resolved_geometry().height)),
&layout::Limits::new(
Size::ZERO,
Size::new(1200., panel.resolved_geometry().height),
),
);
let mut bounds = Bounds {
group_ids: zone
@@ -1116,12 +1249,19 @@ mod island_tests {
}
let start = measure_zone(&panel, &panel.start, Availability::default(), true);
assert_eq!(
start.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
start
.items
.iter()
.map(|(id, _)| id.as_str())
.collect::<Vec<_>>(),
["overview", "workspaces"]
);
let islands = background == PanelStyle::Floating;
assert_eq!(start.items[0].1.x, if islands { padding + 3. } else { 0. });
assert_eq!(start.items[1].1.x - start.items[0].1.x, if islands { 29. } else { 34. });
assert_eq!(
start.items[1].1.x - start.items[0].1.x,
if islands { 29. } else { 34. }
);
assert_eq!(start.islands.len(), panel.start.groups.len());
if islands {
assert_eq!(
@@ -1134,12 +1274,18 @@ mod island_tests {
}
let end = measure_zone(&panel, &panel.end, Availability::default(), true);
assert_eq!(
end.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
end.items
.iter()
.map(|(id, _)| id.as_str())
.collect::<Vec<_>>(),
["quick-settings", "night-light", "recording", "clock"]
);
assert_eq!(end.items[1].1.x - end.items[0].1.x, 21.);
assert_eq!(end.items[2].1.x - end.items[1].1.x, 21.);
assert_eq!(end.items[3].1.x - end.items[2].1.x, if islands { 21. } else { 34. });
assert_eq!(
end.items[3].1.x - end.items[2].1.x,
if islands { 21. } else { 34. }
);
if islands {
assert_eq!(
end.islands[0],
@@ -1154,7 +1300,8 @@ mod island_tests {
if islands {
assert_eq!(center.islands[0].width, 80. + 2. * padding);
}
let empty_title = measure_zone(&panel, &panel.center, Availability::default(), false);
let empty_title =
measure_zone(&panel, &panel.center, Availability::default(), false);
assert!(empty_title.islands.is_empty());
assert!(
empty_title.items.is_empty(),
@@ -1190,16 +1337,27 @@ mod island_tests {
panel.end.groups.insert(0, unavailable);
let result = measure_zone(&panel, &panel.end, Availability::default(), true);
assert_eq!(
result.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
result
.items
.iter()
.map(|(id, _)| id.as_str())
.collect::<Vec<_>>(),
["clock", "other-clock"]
);
assert_eq!(result.items[0].1.x, 7., "an unavailable group adds no space");
assert_eq!(
result.items[0].1.x, 7.,
"an unavailable group adds no space"
);
assert_eq!(result.items[1].1.x, 68.);
assert_eq!(result.islands.len(), 1);
panel.end.groups[1].items.reverse();
let reordered = measure_zone(&panel, &panel.end, Availability::default(), true);
assert_eq!(
reordered.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
reordered
.items
.iter()
.map(|(id, _)| id.as_str())
.collect::<Vec<_>>(),
["other-clock", "clock"]
);
}
@@ -1213,10 +1371,20 @@ mod island_tests {
input[0],
cosmic::iced::Rectangle::new((12., 2.).into(), (81., 25.).into())
);
assert!(!input.iter().any(|region| region.contains((240., 14.).into())));
assert_eq!(input_region(PanelSurface::None, true, &regions), Some(Vec::new()));
assert!(
!input
.iter()
.any(|region| region.contains((240., 14.).into()))
);
assert_eq!(
input_region(PanelSurface::None, true, &regions),
Some(Vec::new())
);
assert_eq!(input_region(PanelSurface::Solid, false, &regions), None);
assert_eq!(input_region(PanelSurface::Solid, true, &regions), Some(Vec::new()));
assert_eq!(
input_region(PanelSurface::Solid, true, &regions),
Some(Vec::new())
);
}
#[test]
@@ -23,27 +23,46 @@ fn a_binding_mode_owns_the_panel_instead_of_hidden_morph_hover_or_menus() {
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
let id = shell.outputs[0].bar;
shell.snapshot.binding_mode = Some("resize".into());
let allocation = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation;
let allocation = shell.outputs[0]
.runtime
.hub
.as_ref()
.unwrap()
.borrow()
.allocation;
drop(shell.hub_hover(id, true));
assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle));
drop(shell.open_hub(id, status_ui::Menu::Calendar));
assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle));
assert_eq!(
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation,
shell.outputs[0]
.runtime
.hub
.as_ref()
.unwrap()
.borrow()
.allocation,
allocation
);
}
#[test]
fn mode_entry_dismisses_existing_popovers_and_collapses_expanded_morph_content() {
for style in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Morph] {
for style in [
ferese_panel::PanelStyle::Flat,
ferese_panel::PanelStyle::Morph,
] {
let mut shell = crate::tests::shell_with_measured_panel();
shell.config.panels[0].style = style;
shell.outputs[0]
.runtime
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
let id = shell.outputs[0].bar;
let collapsed = shell.outputs[0].runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
let collapsed = shell.outputs[0]
.runtime
.hub
.as_ref()
.map(|hub| hub.borrow().allocation);
if collapsed.is_some() {
drop(shell.open_hub(id, status_ui::Menu::Calendar));
assert!(shell.hub_mode(id).unwrap().expanded());
@@ -117,13 +136,19 @@ fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
.hub
.as_ref()
.map(|hub| hub.borrow().allocation)
.unwrap_or([1200, shell.config.panels[0].resolved_geometry().height as u32]);
let viewport = Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32));
.unwrap_or([
1200,
shell.config.panels[0].resolved_geometry().height as u32,
]);
let viewport =
Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32));
let mut view = shell.view_layer(id);
let mut tree = widget::Tree::new(&view);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let mut messages = Vec::new();
view.as_widget_mut().update(
&mut tree,
@@ -141,7 +166,11 @@ fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene),
_ => None,
})
.unwrap_or_else(|| panic!("{style}: mode must replace the previous native clipping and input shape"));
.unwrap_or_else(|| {
panic!(
"{style}: mode must replace the previous native clipping and input shape"
)
});
let mut labels = TextBounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
@@ -150,7 +179,10 @@ fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
"{style}: mode name missing"
);
assert!(
labels.0.iter().any(|(text, _)| text.contains("Esc to exit")),
labels
.0
.iter()
.any(|(text, _)| text.contains("Esc to exit")),
"{style}: exit hint missing"
);
for (text, bounds) in &labels.0 {
@@ -186,7 +218,11 @@ fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
mouse::Cursor::Unavailable,
&viewport,
);
let pixels = renderer.screenshot(Size::new(allocation[0], allocation[1]), 1., Color::TRANSPARENT);
let pixels = renderer.screenshot(
Size::new(allocation[0], allocation[1]),
1.,
Color::TRANSPARENT,
);
assert!(
pixels.chunks_exact(4).any(|pixel| pixel[3] > 0),
"{style}: no visible pixels"
+36 -14
View File
@@ -2,7 +2,10 @@ use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, re
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
use cosmic::{Element, Theme};
pub(super) fn hover<'a, M: 'a>(content: Element<'a, M>, hover: Option<HoverTarget<'a, M>>) -> Element<'a, M> {
pub(super) fn hover<'a, M: 'a>(
content: Element<'a, M>,
hover: Option<HoverTarget<'a, M>>,
) -> Element<'a, M> {
Element::new(Hover { content, hover })
}
@@ -63,7 +66,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn update(
@@ -88,9 +93,12 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
target: &hover.id,
bounds: None,
};
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, &mut target);
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut target,
);
if matches!(event, Event::Mouse(mouse::Event::CursorLeft))
|| !cursor.is_over(*viewport)
|| !target.bounds.is_some_and(|bounds| cursor.is_over(bounds))
@@ -125,9 +133,15 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
@@ -138,9 +152,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
@@ -163,8 +181,12 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
+15 -6
View File
@@ -18,10 +18,15 @@ impl FereseShell {
let foreground = color_with_opacity(theme.text_primary, opacity);
let outline = if battery.is_some_and(|battery| {
battery.percent < self.config.status.low_battery_threshold
&& matches!(battery.status.as_str(), "Discharging" | "Pending discharge" | "Empty")
&& matches!(
battery.status.as_str(),
"Discharging" | "Pending discharge" | "Empty"
)
}) {
palette.error.scale_alpha(opacity)
} else if battery.is_some_and(|battery| matches!(battery.status.as_str(), "Charging" | "Pending charge")) {
} else if battery
.is_some_and(|battery| matches!(battery.status.as_str(), "Charging" | "Pending charge"))
{
palette.accent.scale_alpha(opacity)
} else {
foreground
@@ -66,15 +71,19 @@ impl FereseShell {
},
..Default::default()
}));
let terminal =
container(Space::new().width(1.5).height(4)).class(theme::Container::custom(move |_| container::Style {
let terminal = container(Space::new().width(1.5).height(4)).class(
theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(outline)),
border: Border {
radius: 1.0.into(),
..Default::default()
},
..Default::default()
}));
row![body, terminal].spacing(1).align_y(Alignment::Center).into()
}),
);
row![body, terminal]
.spacing(1)
.align_y(Alignment::Center)
.into()
}
}
+80 -26
View File
@@ -28,11 +28,13 @@ fn stream(_: &()) -> impl futures::Stream<Item = String> + use<> {
let (send, receive) = tokio::sync::mpsc::channel(1);
let stop = match UnixStream::pair() {
Ok((stop, cancel)) => {
match std::thread::Builder::new().name("ferese-clock".into()).spawn(move || {
if let Err(error) = watch(send, cancel) {
eprintln!("ferese-shell: clock subscription: {error}");
}
}) {
match std::thread::Builder::new()
.name("ferese-clock".into())
.spawn(move || {
if let Err(error) = watch(send, cancel) {
eprintln!("ferese-shell: clock subscription: {error}");
}
}) {
Ok(_) => Some(stop),
Err(error) => {
eprintln!("ferese-shell: clock worker: {error}");
@@ -52,8 +54,9 @@ fn stream(_: &()) -> impl futures::Stream<Item = String> + use<> {
}
fn watch(send: tokio::sync::mpsc::Sender<String>, cancel: UnixStream) -> io::Result<()> {
let timer =
owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })?;
let timer = owned_fd(unsafe {
libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK)
})?;
let (mut system, mut paths) = zones();
let mut timezone = ZoneWatch::new(&paths)?;
let mut previous = None;
@@ -119,7 +122,10 @@ fn owned_fd(fd: libc::c_int) -> io::Result<OwnedFd> {
fn arm(timer: &OwnedFd, deadline: i128) -> io::Result<bool> {
let spec = libc::itimerspec {
it_interval: libc::timespec { tv_sec: 0, tv_nsec: 0 },
it_interval: libc::timespec {
tv_sec: 0,
tv_nsec: 0,
},
it_value: libc::timespec {
tv_sec: deadline.div_euclid(SECOND) as _,
tv_nsec: deadline.rem_euclid(SECOND) as _,
@@ -146,7 +152,10 @@ fn read_timer(timer: &OwnedFd) -> io::Result<()> {
let error = io::Error::last_os_error();
// A wall-clock adjustment cancels the timer. Recompute labels/deadline
// just as after expiration; CLOCK_REALTIME also advances during suspend.
if !matches!(error.raw_os_error(), Some(libc::ECANCELED | libc::EAGAIN | libc::EINTR)) {
if !matches!(
error.raw_os_error(),
Some(libc::ECANCELED | libc::EAGAIN | libc::EINTR)
) {
return Err(error);
}
}
@@ -154,12 +163,19 @@ fn read_timer(timer: &OwnedFd) -> io::Result<()> {
}
fn zones() -> (TimeZone, Vec<PathBuf>) {
let mut paths = vec![PathBuf::from("/etc/localtime"), PathBuf::from("/etc/timezone")];
let mut paths = vec![
PathBuf::from("/etc/localtime"),
PathBuf::from("/etc/timezone"),
];
// Load the file directly: Jiff's system timezone cache lasts five minutes.
let system = match std::env::var("TZ") {
Ok(zone) if zone.is_empty() => TimeZone::UTC,
Ok(zone) => named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system),
Err(_) => file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system),
Ok(zone) => {
named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system)
}
Err(_) => {
file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system)
}
};
(system, paths)
}
@@ -169,7 +185,11 @@ fn named_zone(name: &str, paths: &mut Vec<PathBuf>) -> Option<TimeZone> {
if name.eq_ignore_ascii_case("UTC") {
return Some(TimeZone::UTC);
}
for root in ["/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/etc/zoneinfo"] {
for root in [
"/usr/share/zoneinfo",
"/usr/share/lib/zoneinfo",
"/etc/zoneinfo",
] {
let path = Path::new(root).join(name);
if let Some(zone) = file_zone(&path, paths) {
return Some(zone);
@@ -208,7 +228,9 @@ fn zone_deadline(now: &Zoned) -> i128 {
now.time_zone()
.following(now.timestamp())
.next()
.map_or(next, |transition| next.min(transition.timestamp().as_nanosecond()))
.map_or(next, |transition| {
next.min(transition.timestamp().as_nanosecond())
})
}
struct ZoneWatch {
@@ -246,7 +268,13 @@ impl ZoneWatch {
let mut buffer = [0u8; 8192];
let mut changed = false;
loop {
let size = unsafe { libc::read(self.fd.as_raw_fd(), buffer.as_mut_ptr().cast(), buffer.len()) };
let size = unsafe {
libc::read(
self.fd.as_raw_fd(),
buffer.as_mut_ptr().cast(),
buffer.len(),
)
};
if size < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::WouldBlock {
@@ -262,16 +290,24 @@ impl ZoneWatch {
}
let mut offset = 0;
while offset + std::mem::size_of::<libc::inotify_event>() <= size as usize {
let event = unsafe { (buffer.as_ptr().add(offset) as *const libc::inotify_event).read_unaligned() };
let event = unsafe {
(buffer.as_ptr().add(offset) as *const libc::inotify_event).read_unaligned()
};
offset += std::mem::size_of::<libc::inotify_event>();
let end = offset + event.len as usize;
if end > size as usize {
break;
}
let name = &buffer[offset..end];
let name = &name[..name.iter().position(|byte| *byte == 0).unwrap_or(name.len())];
let name = &name[..name
.iter()
.position(|byte| *byte == 0)
.unwrap_or(name.len())];
changed |= event.mask
& (libc::IN_Q_OVERFLOW | libc::IN_IGNORED | libc::IN_MOVE_SELF | libc::IN_DELETE_SELF)
& (libc::IN_Q_OVERFLOW
| libc::IN_IGNORED
| libc::IN_MOVE_SELF
| libc::IN_DELETE_SELF)
!= 0
|| self
.names
@@ -354,19 +390,33 @@ mod tests {
}
let zone = TimeZone::get("America/New_York").unwrap();
for (stamp, expected, label) in [
("2026-03-08T06:59:59Z", "2026-03-08T07:00:00Z", "8 mar, 3:00 am"),
("2026-11-01T05:59:59Z", "2026-11-01T06:00:00Z", "1 nov, 1:00 am"),
(
"2026-03-08T06:59:59Z",
"2026-03-08T07:00:00Z",
"8 mar, 3:00 am",
),
(
"2026-11-01T05:59:59Z",
"2026-11-01T06:00:00Z",
"1 nov, 1:00 am",
),
] {
let now: Timestamp = stamp.parse().unwrap();
let expected: Timestamp = expected.parse().unwrap();
assert_eq!(zone_deadline(&now.to_zoned(zone.clone())), expected.as_nanosecond());
assert_eq!(
zone_deadline(&now.to_zoned(zone.clone())),
expected.as_nanosecond()
);
assert_eq!(labels(expected, &zone), label);
}
let zone = TimeZone::fixed(jiff::tz::Offset::from_seconds(30).unwrap());
let now: Timestamp = "2026-10-04T12:34:20Z".parse().unwrap();
assert_eq!(
zone_deadline(&now.to_zoned(zone)),
"2026-10-04T12:34:30Z".parse::<Timestamp>().unwrap().as_nanosecond()
"2026-10-04T12:34:30Z"
.parse::<Timestamp>()
.unwrap()
.as_nanosecond()
);
}
@@ -389,9 +439,10 @@ mod tests {
#[test]
fn absolute_timer_and_subscription_drop_wake_without_recurring_poll() {
let timer =
owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })
.unwrap();
let timer = owned_fd(unsafe {
libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK)
})
.unwrap();
arm(&timer, Timestamp::now().as_nanosecond() - SECOND).unwrap();
let mut poll = libc::pollfd {
fd: timer.as_raw_fd(),
@@ -411,7 +462,10 @@ mod tests {
});
assert!(receive.blocking_recv().is_some());
drop(stop);
done_receive.recv_timeout(Duration::from_secs(2)).unwrap().unwrap();
done_receive
.recv_timeout(Duration::from_secs(2))
.unwrap()
.unwrap();
assert!(receive.blocking_recv().is_none());
}
}
+32 -11
View File
@@ -28,7 +28,10 @@ impl SessionConnection {
}
fn request(&self, command: &str, args: Value) -> Result<Value, String> {
let mut stream = self.stream.lock().map_err(|_| "Compositor connection failed")?;
let mut stream = self
.stream
.lock()
.map_err(|_| "Compositor connection failed")?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
@@ -37,7 +40,8 @@ impl SessionConnection {
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response = ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
@@ -80,8 +84,12 @@ impl Approval {
}
pub fn prepare(&self) -> Result<(), String> {
if matches!(self.mode, Mode::Shutdown | Mode::Suspend) && login_inhibitors(self.mode)? != self.external {
return Err("Applications changed their inhibitors; review the confirmation again".into());
if matches!(self.mode, Mode::Shutdown | Mode::Suspend)
&& login_inhibitors(self.mode)? != self.external
{
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
}
if self.mode == Mode::Shutdown {
self.connection
@@ -94,7 +102,9 @@ impl Approval {
} else if self.mode == Mode::Suspend {
let state = call("get-session-state")?;
if state["inhibitor-revision"].as_u64() != Some(u64::from(self.revision)) {
return Err("Applications changed their inhibitors; review the confirmation again".into());
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
}
}
Ok(())
@@ -117,12 +127,19 @@ fn login_inhibitors(mode: Mode) -> Result<Vec<String>, String> {
"org.freedesktop.login1.Manager",
)
.map_err(|error| error.to_string())?;
let inhibitors: Vec<(String, String, String, String, u32, u32)> =
proxy.call("ListInhibitors", &()).map_err(|error| error.to_string())?;
let what = if mode == Mode::Suspend { "sleep" } else { "shutdown" };
let inhibitors: Vec<(String, String, String, String, u32, u32)> = proxy
.call("ListInhibitors", &())
.map_err(|error| error.to_string())?;
let what = if mode == Mode::Suspend {
"sleep"
} else {
"shutdown"
};
let mut reasons = inhibitors
.into_iter()
.filter(|(flags, _, _, inhibition, _, _)| inhibition == "block" && flags.split(':').any(|flag| flag == what))
.filter(|(flags, _, _, inhibition, _, _)| {
inhibition == "block" && flags.split(':').any(|flag| flag == what)
})
.map(|(_, app, reason, _, _, _)| format!("{app}: {reason}"))
.collect::<Vec<_>>();
reasons.sort();
@@ -162,7 +179,9 @@ pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
}
}
let flags = if mode == Mode::Suspend { 4 } else { 1 };
let entries = state["inhibitors"].as_array().ok_or("Invalid inhibitor list")?;
let entries = state["inhibitors"]
.as_array()
.ok_or("Invalid inhibitor list")?;
let mut reasons = entries
.iter()
.filter(|entry| entry["flags"].as_u64().unwrap_or(0) & flags != 0)
@@ -171,7 +190,9 @@ pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
.as_str()
.filter(|app| !app.is_empty())
.unwrap_or("Application");
let reason = entry["reason"].as_str().unwrap_or("Requested session inhibition");
let reason = entry["reason"]
.as_str()
.unwrap_or("Requested session inhibition");
format!("{app}: {reason}")
})
.collect::<Vec<_>>();
+76 -30
View File
@@ -92,8 +92,10 @@ impl Default for ShellTheme {
bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255],
surface_base: [17, 24, 33, 255],
surface_raised: parse_color(&ferese_config::theme::default_tokens().colors.surface_raised)
.expect("valid theme surface color"),
surface_raised: parse_color(
&ferese_config::theme::default_tokens().colors.surface_raised,
)
.expect("valid theme surface color"),
surface_popover: [17, 24, 33, 255],
text_primary: [244, 247, 251, 255],
text_muted: [135, 147, 162, 255],
@@ -130,7 +132,8 @@ impl ShellTheme {
}
pub(crate) fn palette(self) -> ferese_theme::Palette {
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let color =
|[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let surface = color(self.surface_base);
ferese_theme::Palette {
appearance: self.appearance,
@@ -352,10 +355,15 @@ pub(crate) fn load(snapshot: &ferese_ipc::theme::Snapshot) -> Result<ShellConfig
load_path(&path, snapshot)
}
fn load_path(path: &std::path::Path, snapshot: &ferese_ipc::theme::Snapshot) -> Result<ShellConfig, String> {
fn load_path(
path: &std::path::Path,
snapshot: &ferese_ipc::theme::Snapshot,
) -> Result<ShellConfig, String> {
match fs::symlink_metadata(path) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(ShellConfig::default()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(ShellConfig::default());
}
Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
}
let mut source = String::new();
@@ -368,7 +376,8 @@ fn load_path(path: &std::path::Path, snapshot: &ferese_ipc::theme::Snapshot) ->
if source.len() > ferese_config::file::LIMIT {
return Err(format!("{} exceeds 60 KiB", path.display()));
}
parse_source(&source, snapshot).map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
parse_source(&source, snapshot)
.map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
}
pub(crate) fn parse_source(
@@ -400,11 +409,18 @@ fn parse_document(
ferese_panel::validate(panels).map_err(ferese_config::Error::from)?;
}
config.animations.validate().map_err(ferese_config::Error::from)?;
config.notifications.validate().map_err(ferese_config::Error::from)?;
config
.animations
.validate()
.map_err(ferese_config::Error::from)?;
config
.notifications
.validate()
.map_err(ferese_config::Error::from)?;
let mut theme = shell_theme(&config.theme);
theme.appearance = appearance;
theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0);
theme.material_radius =
nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0);
theme.bar_radius = theme.material_radius;
Ok(ShellConfig {
@@ -420,7 +436,9 @@ fn parse_document(
wallpaper: config.theme.background,
theme,
theme_mode: mode,
panels: config.panels.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
panels: config
.panels
.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
status: StatusConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status
@@ -433,7 +451,8 @@ fn parse_document(
impl ShellConfig {
pub(crate) fn apply_theme(&mut self, theme: &ferese_config::theme::ResolvedTheme) {
let config: ThemeConfig = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
let config: ThemeConfig =
serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
self.theme = shell_theme(&config);
self.theme.appearance = theme.appearance;
self.theme.high_contrast = theme.accessibility.increase_contrast;
@@ -472,16 +491,21 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
reduce_transparency: defaults.reduce_transparency,
material_radius: defaults.material_radius,
bar_background: {
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
let mut background =
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
background[3] = alpha;
background
},
bar_text_primary: parse_color(&theme.surface.bar.text_primary).unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted).unwrap_or(defaults.bar_text_muted),
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
.unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted)
.unwrap_or(defaults.bar_text_muted),
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
surface_raised: parse_color(&theme.colors.surface_raised).unwrap_or(defaults.surface_raised),
surface_raised: parse_color(&theme.colors.surface_raised)
.unwrap_or(defaults.surface_raised),
surface_popover: {
let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
let mut color =
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = alpha;
color
},
@@ -492,11 +516,15 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
bar_radius: nonnegative_or(theme.geometry.shell_radius.unwrap_or(14.0), defaults.bar_radius),
bar_radius: nonnegative_or(
theme.geometry.shell_radius.unwrap_or(14.0),
defaults.bar_radius,
),
control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap),
shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y),
shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur),
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity).clamp(0.0, 1.0),
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity)
.clamp(0.0, 1.0),
}
}
@@ -594,13 +622,21 @@ mod tests {
assert_eq!(config.panels[0].morph.media_height, 184.);
assert!(!config.panels[0].morph.peek_on_hover);
assert!(
parse_test_source("panel main { style morph; }\n").unwrap().panels[0]
parse_test_source("panel main { style morph; }\n")
.unwrap()
.panels[0]
.morph
.peek_on_hover
);
assert!(parse_test_source("panel main { style morph; morph { peek-on-hover 1; }; }\n").is_err());
assert!(parse_test_source("panel main { style morph; morph { idle-wdth 180; }; }\n").is_err());
assert!(parse_test_source("panel main { style morph; morph { idle-width -1; }; }\n").is_err());
assert!(
parse_test_source("panel main { style morph; morph { peek-on-hover 1; }; }\n").is_err()
);
assert!(
parse_test_source("panel main { style morph; morph { idle-wdth 180; }; }\n").is_err()
);
assert!(
parse_test_source("panel main { style morph; morph { idle-width -1; }; }\n").is_err()
);
}
#[test]
@@ -659,9 +695,14 @@ mod tests {
.theme;
let mut config = parse_test_source(&source).unwrap();
config.apply_theme(&resolved);
let effective = config.theme.panel_opacity(config.panels[0].background_opacity);
let effective = config
.theme
.panel_opacity(config.panels[0].background_opacity);
assert_eq!(effective, if reduce { 1. } else { opacity });
assert_eq!(effective, resolved.panel_opacity(config.panels[0].background_opacity));
assert_eq!(
effective,
resolved.panel_opacity(config.panels[0].background_opacity)
);
}
}
}
@@ -671,7 +712,8 @@ mod tests {
fn rejects_removed_physics_keys_before_publication() {
for property in ["spring", "viewport-spring"] {
for coefficient in ["mass", "stiffness", "damping", "damping-ratio"] {
let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let source =
format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let error = parse_test_source(&source).unwrap_err().to_string();
assert!(error.contains("unknown field"), "{error}");
}
@@ -738,7 +780,8 @@ mod tests {
#[test]
fn group_surfaces_do_not_change_modal_opacity() {
let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
let normal =
parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
let islands = parse_test_source(
"panel main { style floating; }\ntheme { material { style translucent; opacity 0.7; }; }",
)
@@ -793,7 +836,10 @@ theme {
config.theme.typography.font_family.as_deref(),
Some("JetBrainsMono Nerd Font")
);
assert_eq!(config.theme.background.path, Some(PathBuf::from("/tmp/wallpaper.png")));
assert_eq!(
config.theme.background.path,
Some(PathBuf::from("/tmp/wallpaper.png"))
);
assert_eq!(config.theme.background.mode, WallpaperMode::Fit);
}
@@ -813,8 +859,7 @@ theme {
fn loader_reports_invalid_configuration_without_replacing_it() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
let source =
"notifications { do-not-disturb #true; }\nstatus { bar-layout islands; battery-percentage #false; }";
let source = "notifications { do-not-disturb #true; }\nstatus { bar-layout islands; battery-percentage #false; }";
std::fs::write(&path, source).unwrap();
let error = super::load_path(&path, &ferese_theme_client::service::fallback()).unwrap_err();
assert!(error.contains("bar_layout"), "{error}");
@@ -842,7 +887,8 @@ theme {
)
.is_ok()
);
let error = super::load_path(directory.path(), &ferese_theme_client::service::fallback()).unwrap_err();
let error = super::load_path(directory.path(), &ferese_theme_client::service::fallback())
.unwrap_err();
assert!(error.contains("cannot read"), "{error}");
let path = directory.path().join("config.kdl");
std::os::unix::fs::symlink(directory.path().join("missing-target"), &path).unwrap();
+157 -49
View File
@@ -21,7 +21,10 @@ pub(crate) struct ShellSnapshot {
}
impl ShellSnapshot {
pub(crate) fn workspaces_for_output(&self, output: Option<u64>) -> impl Iterator<Item = &WorkspaceSnapshot> {
pub(crate) fn workspaces_for_output(
&self,
output: Option<u64>,
) -> impl Iterator<Item = &WorkspaceSnapshot> {
self.workspaces
.iter()
.filter(move |workspace| output.is_some() && workspace.output == output)
@@ -60,7 +63,10 @@ pub(crate) struct WindowSnapshot {
pub(crate) maximized: bool,
#[cfg_attr(
not(test),
expect(dead_code, reason = "The current bar does not display window placement.")
expect(
dead_code,
reason = "The current bar does not display window placement."
)
)]
pub(crate) floating: bool,
}
@@ -184,16 +190,30 @@ impl ShellControl {
loop {
match self.updates.try_recv() {
Ok(ControlUpdate::Logout(serial, output)) => poll.commands.push(ControlCommand::Logout(serial, output)),
Ok(ControlUpdate::ToggleDisplays(output)) => poll.commands.push(ControlCommand::ToggleDisplays(output)),
Ok(ControlUpdate::MonitorsChanged) => poll.commands.push(ControlCommand::MonitorsChanged),
Ok(ControlUpdate::DismissLauncher) => poll.commands.push(ControlCommand::DismissLauncher),
Ok(ControlUpdate::Thumbnail(token, window, image)) => {
poll.commands.push(ControlCommand::Thumbnail(token, window, image))
Ok(ControlUpdate::Logout(serial, output)) => {
poll.commands.push(ControlCommand::Logout(serial, output))
}
Ok(ControlUpdate::ToggleDisplays(output)) => {
poll.commands.push(ControlCommand::ToggleDisplays(output))
}
Ok(ControlUpdate::MonitorsChanged) => {
poll.commands.push(ControlCommand::MonitorsChanged)
}
Ok(ControlUpdate::DismissLauncher) => {
poll.commands.push(ControlCommand::DismissLauncher)
}
Ok(ControlUpdate::Thumbnail(token, window, image)) => poll
.commands
.push(ControlCommand::Thumbnail(token, window, image)),
Ok(ControlUpdate::ToggleLauncher(output)) => {
poll.commands.push(ControlCommand::ToggleLauncher(output))
}
Ok(ControlUpdate::ToggleGuide(output)) => {
poll.commands.push(ControlCommand::ToggleGuide(output))
}
Ok(ControlUpdate::LogoutCancelled(serial)) => {
poll.commands.push(ControlCommand::CancelLogout(serial))
}
Ok(ControlUpdate::ToggleLauncher(output)) => poll.commands.push(ControlCommand::ToggleLauncher(output)),
Ok(ControlUpdate::ToggleGuide(output)) => poll.commands.push(ControlCommand::ToggleGuide(output)),
Ok(ControlUpdate::LogoutCancelled(serial)) => poll.commands.push(ControlCommand::CancelLogout(serial)),
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),
@@ -221,7 +241,8 @@ impl ShellControl {
}
let (hi, lo) = split_id(window);
let (workspace_hi, workspace_lo) = split_id(workspace);
self.shell.move_window_to_workspace(hi, lo, workspace_hi, workspace_lo);
self.shell
.move_window_to_workspace(hi, lo, workspace_hi, workspace_lo);
let _ = self.connection.flush();
}
@@ -240,7 +261,10 @@ impl ShellControl {
#[cfg_attr(
not(test),
expect(dead_code, reason = "Window-targeting API; the current UI targets workspaces.")
expect(
dead_code,
reason = "Window-targeting API; the current UI targets workspaces."
)
)]
pub(crate) fn close_window(&self, id: u64) {
let (hi, lo) = split_id(id);
@@ -250,7 +274,10 @@ impl ShellControl {
#[cfg_attr(
not(test),
expect(dead_code, reason = "Window-targeting API; the current UI targets workspaces.")
expect(
dead_code,
reason = "Window-targeting API; the current UI targets workspaces."
)
)]
pub(crate) fn select_overview_window(&self, id: u64) {
let (hi, lo) = split_id(id);
@@ -282,8 +309,8 @@ impl ShellControl {
}
fn control_connection() -> Result<Connection, Box<dyn Error>> {
let raw_fd =
std::env::var_os("FERESE_SHELL_CONTROL_SOCKET").ok_or("Ferese did not provide a shell-control connection")?;
let raw_fd = std::env::var_os("FERESE_SHELL_CONTROL_SOCKET")
.ok_or("Ferese did not provide a shell-control connection")?;
let fd = raw_fd
.to_str()
.ok_or("FERESE_SHELL_CONTROL_SOCKET is not valid UTF-8")?
@@ -346,9 +373,11 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
window_hi,
window_lo,
} => {
let _ = state
.sender
.send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), None));
let _ = state.sender.send(ControlUpdate::Thumbnail(
token,
join_id(window_hi, window_lo),
None,
));
}
ferese_shell_v1::Event::Thumbnail {
token,
@@ -370,14 +399,20 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
}
let mut bytes = vec![0; size];
file.read_exact(&mut bytes).ok()?;
Some(cosmic::widget::image::Handle::from_rgba(width, height, bytes))
Some(cosmic::widget::image::Handle::from_rgba(
width, height, bytes,
))
})();
let _ = state
.sender
.send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), image));
let _ = state.sender.send(ControlUpdate::Thumbnail(
token,
join_id(window_hi, window_lo),
image,
));
}
ferese_shell_v1::Event::ToggleLauncher { output_name } => {
let _ = state.sender.send(ControlUpdate::ToggleLauncher(output_name));
let _ = state
.sender
.send(ControlUpdate::ToggleLauncher(output_name));
}
ferese_shell_v1::Event::ConfigBegin => state.config = Some(String::new()),
ferese_shell_v1::Event::ConfigChunk { source } => {
@@ -441,7 +476,8 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
state: window_state,
} => {
// WEnum::Unknown holds the entire bitfield, not just new bits.
let flags = ferese_shell_v1::WindowState::from_bits_truncate(u32::from(window_state));
let flags =
ferese_shell_v1::WindowState::from_bits_truncate(u32::from(window_state));
state.pending.windows.push(WindowSnapshot {
id: join_id(window_hi, window_lo),
@@ -479,14 +515,25 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
ferese_shell_v1::Event::Capabilities { .. } => {
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
}
ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => {
let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone()));
ferese_shell_v1::Event::SnapshotEnd { serial }
if state.serial.take() == Some(serial) =>
{
let _ = state
.sender
.send(ControlUpdate::Snapshot(state.pending.clone()));
}
ferese_shell_v1::Event::LogoutRequested { serial, output_name } => {
let _ = state.sender.send(ControlUpdate::Logout(serial, output_name));
ferese_shell_v1::Event::LogoutRequested {
serial,
output_name,
} => {
let _ = state
.sender
.send(ControlUpdate::Logout(serial, output_name));
}
ferese_shell_v1::Event::ToggleDisplayMode { output_name } => {
let _ = state.sender.send(ControlUpdate::ToggleDisplays(output_name));
let _ = state
.sender
.send(ControlUpdate::ToggleDisplays(output_name));
}
ferese_shell_v1::Event::MonitorStateChanged => {
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
@@ -636,7 +683,9 @@ mod tests {
&control,
&mut state,
&queue,
ferese_shell_v1::Event::BindingMode { name: "resize".into() },
ferese_shell_v1::Event::BindingMode {
name: "resize".into(),
},
);
assert!(control.poll().snapshot.is_none());
dispatch_event(
@@ -645,7 +694,10 @@ mod tests {
&queue,
ferese_shell_v1::Event::SnapshotEnd { serial: 1 },
);
assert_eq!(control.poll().snapshot.unwrap().binding_mode.as_deref(), Some("resize"));
assert_eq!(
control.poll().snapshot.unwrap().binding_mode.as_deref(),
Some("resize")
);
dispatch_event(
&control,
&mut state,
@@ -656,7 +708,9 @@ mod tests {
&control,
&mut state,
&queue,
ferese_shell_v1::Event::BindingMode { name: String::new() },
ferese_shell_v1::Event::BindingMode {
name: String::new(),
},
);
dispatch_event(
&control,
@@ -677,7 +731,10 @@ mod tests {
ferese_shell_v1::Event::SnapshotBegin { serial: 7 },
);
let ids = [0x1234_5678_9abc_def0, 0xfedc_ba98_7654_3210];
for (id, flags) in [(ids[0], 0), (ids[1], ferese_shell_v1::WindowState::Floating.bits())] {
for (id, flags) in [
(ids[0], 0),
(ids[1], ferese_shell_v1::WindowState::Floating.bits()),
] {
dispatch_event(&control, &mut state, &queue, window_event(id, flags));
}
assert!(control.poll().snapshot.is_none());
@@ -688,7 +745,14 @@ mod tests {
ferese_shell_v1::Event::SnapshotEnd { serial: 7 },
);
let snapshot = control.poll().snapshot.unwrap().clone();
assert_eq!(snapshot.windows.iter().map(|window| window.id).collect::<Vec<_>>(), ids);
assert_eq!(
snapshot
.windows
.iter()
.map(|window| window.id)
.collect::<Vec<_>>(),
ids
);
let [tiled, floating] = snapshot.windows.as_slice() else {
panic!("expected two windows")
};
@@ -818,11 +882,26 @@ mod tests {
let cases = [
(known.bits(), [true, true, true, true, false]),
(known.bits() | future, [true, true, true, true, false]),
(Flags::Focused.bits() | future, [true, false, false, false, false]),
(Flags::Urgent.bits() | future, [false, true, false, false, false]),
(Flags::Fullscreen.bits() | future, [false, false, true, false, false]),
(Flags::Floating.bits() | future, [false, false, false, true, false]),
(Flags::Maximized.bits() | future, [false, false, false, false, true]),
(
Flags::Focused.bits() | future,
[true, false, false, false, false],
),
(
Flags::Urgent.bits() | future,
[false, true, false, false, false],
),
(
Flags::Fullscreen.bits() | future,
[false, false, true, false, false],
),
(
Flags::Floating.bits() | future,
[false, false, false, true, false],
),
(
Flags::Maximized.bits() | future,
[false, false, false, false, true],
),
(future, [false; 5]),
(0, [false; 5]),
];
@@ -833,7 +912,12 @@ mod tests {
ferese_shell_v1::Event::SnapshotBegin { serial: 1 },
);
for (index, (bits, _)) in cases.iter().enumerate() {
dispatch_event(&control, &mut state, &queue, window_event(index as u64 + 1, *bits));
dispatch_event(
&control,
&mut state,
&queue,
window_event(index as u64 + 1, *bits),
);
}
dispatch_event(
&control,
@@ -925,13 +1009,19 @@ mod workspace_tests {
focused: output == 1 && index == 1,
};
let snapshot = ShellSnapshot {
workspaces: vec![workspace(7, 1, 1, 2), workspace(9, 1, 2, 0), workspace(3, 2, 1, 0)],
workspaces: vec![
workspace(7, 1, 1, 2),
workspace(9, 1, 2, 0),
workspace(3, 2, 1, 0),
],
..Default::default()
};
let left = snapshot.workspaces_for_output(Some(1)).collect::<Vec<_>>();
assert_eq!(
left.iter().map(|workspace| workspace.id).collect::<Vec<_>>(),
left.iter()
.map(|workspace| workspace.id)
.collect::<Vec<_>>(),
vec![7, 9]
);
assert_eq!(left[0].window_count, 2);
@@ -961,20 +1051,38 @@ mod wake_tests {
sender,
wake: wake.clone(),
};
sender.send(ControlUpdate::OverviewState(true)).ok().unwrap();
sender
.send(ControlUpdate::OverviewState(true))
.ok()
.unwrap();
let stream = wake.stream();
cosmic::iced::futures::pin_mut!(stream);
assert_eq!(stream.next().now_or_never(), Some(Some(())));
assert!(matches!(receiver.try_recv(), Ok(ControlUpdate::OverviewState(true))));
assert!(matches!(
receiver.try_recv(),
Ok(ControlUpdate::OverviewState(true))
));
assert_eq!(stream.next().now_or_never(), None);
sender.send(ControlUpdate::Logout(7, "display".into())).ok().unwrap();
sender
.send(ControlUpdate::Logout(7, "display".into()))
.ok()
.unwrap();
sender.send(ControlUpdate::LogoutCancelled(7)).ok().unwrap();
sender.send(ControlUpdate::Disconnected).ok().unwrap();
assert_eq!(stream.next().now_or_never(), Some(Some(())));
assert!(matches!(receiver.try_recv(), Ok(ControlUpdate::Logout(7, _))));
assert!(matches!(receiver.try_recv(), Ok(ControlUpdate::LogoutCancelled(7))));
assert!(matches!(receiver.try_recv(), Ok(ControlUpdate::Disconnected)));
assert!(matches!(
receiver.try_recv(),
Ok(ControlUpdate::Logout(7, _))
));
assert!(matches!(
receiver.try_recv(),
Ok(ControlUpdate::LogoutCancelled(7))
));
assert!(matches!(
receiver.try_recv(),
Ok(ControlUpdate::Disconnected)
));
}
}
+108 -56
View File
@@ -190,11 +190,15 @@ impl Model {
}
pub fn external_connected(&self) -> bool {
self.inventory.as_ref().is_some_and(Inventory::external_connected)
self.inventory
.as_ref()
.is_some_and(Inventory::external_connected)
}
pub fn pending(&self) -> bool {
self.inventory.as_ref().is_some_and(|inventory| inventory.pending)
self.inventory
.as_ref()
.is_some_and(|inventory| inventory.pending)
}
fn update(&mut self, inventory: Inventory) {
@@ -249,7 +253,11 @@ impl FereseShell {
Task::none()
}
pub(super) fn select_display_mode(&mut self, mode: Mode, preserve_focus: bool) -> Task<Message> {
pub(super) fn select_display_mode(
&mut self,
mode: Mode,
preserve_focus: bool,
) -> Task<Message> {
if !self.display_mode.busy
&& !self.display_mode.pending()
&& self
@@ -268,10 +276,12 @@ impl FereseShell {
// Arrow/number selection leaves Enter for the default action,
// rather than activating a previously clicked or tabbed mode.
return cosmic::iced::advanced::widget::operate(cosmic::iced::advanced::widget::operation::scoped(
cosmic::iced::advanced::widget::Id::new("ferese-system-modal-controls"),
cosmic::iced::advanced::widget::operation::focusable::unfocus(),
));
return cosmic::iced::advanced::widget::operate(
cosmic::iced::advanced::widget::operation::scoped(
cosmic::iced::advanced::widget::Id::new("ferese-system-modal-controls"),
cosmic::iced::advanced::widget::operation::focusable::unfocus(),
),
);
}
Task::none()
@@ -289,7 +299,9 @@ impl FereseShell {
.ok()
.filter(|number| (1..=4).contains(number))
.map(|number| Mode::ALL[number - 1]),
Key::Named(Named::ArrowLeft | Named::ArrowUp | Named::ArrowRight | Named::ArrowDown) => {
Key::Named(
Named::ArrowLeft | Named::ArrowUp | Named::ArrowRight | Named::ArrowDown,
) => {
let backwards = matches!(key, Key::Named(Named::ArrowLeft | Named::ArrowUp));
(1..=4)
.map(|step| Mode::ALL[(current + if backwards { 4 - step } else { step }) % 4])
@@ -329,7 +341,12 @@ impl FereseShell {
}
self.display_operation(true, move || {
compositor_ipc::call(if keep { "output-confirm" } else { "output-revert" }).map(|_| ())
compositor_ipc::call(if keep {
"output-confirm"
} else {
"output-revert"
})
.map(|_| ())
})
}
@@ -383,11 +400,16 @@ impl FereseShell {
}
}
pub(super) fn display_mode_rows(&self) -> cosmic::widget::Column<'_, cosmic::Action<Message>, cosmic::Theme> {
pub(super) fn display_mode_rows(
&self,
) -> cosmic::widget::Column<'_, cosmic::Action<Message>, cosmic::Theme> {
let palette = self.config.theme.palette();
let material_opacity = self.config.theme.material_opacity();
let model = &self.display_mode;
let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible);
let focus_visible = self
.system_modal
.as_ref()
.is_some_and(|modal| modal.focus_visible);
let pending = model.pending();
let mut header = row![
self.render.text("Display mode").size(17),
@@ -441,17 +463,24 @@ impl FereseShell {
material_opacity,
focus_visible,
))
.on_press_maybe(available.then_some(cosmic::Action::App(Message::SelectDisplayMode(mode)))),
.on_press_maybe(
available.then_some(cosmic::Action::App(Message::SelectDisplayMode(mode))),
),
);
}
let mut rows = cosmic::widget::column![header, cards].spacing(14);
if let Some(inventory) = &model.inventory {
let mut displays = cosmic::widget::column::with_capacity(inventory.displays.len()).spacing(6);
let mut displays =
cosmic::widget::column::with_capacity(inventory.displays.len()).spacing(6);
for display in &inventory.displays {
displays = displays.push(
row![
ferese_theme::icons::tinted(ferese_theme::icons::DISPLAY, 16, palette.muted),
ferese_theme::icons::tinted(
ferese_theme::icons::DISPLAY,
16,
palette.muted
),
self.render
.text(&display.label)
.size(12)
@@ -476,7 +505,11 @@ impl FereseShell {
.inventory
.as_ref()
.map_or(15, |inventory| inventory.timeout)
.saturating_sub(model.confirmation_since.map_or(0, |since| since.elapsed().as_secs()));
.saturating_sub(
model
.confirmation_since
.map_or(0, |since| since.elapsed().as_secs()),
);
format!("Keep this configuration? Reverts in {remaining}s.")
} else {
"Arrow keys or 1–4 to choose. Enter to apply.".to_owned()
@@ -497,43 +530,48 @@ impl FereseShell {
.inventory
.as_ref()
.is_some_and(|inventory| inventory.available(model.selected)));
rows.push(self.render.text(hint).size(12).class(theme::Text::Color(palette.muted)))
.push(
container(
row![
cosmic::iced::widget::Space::new().width(Length::Fill),
ferese_theme::controls::text_button(
if pending { "Revert" } else { "Cancel" },
self.render.font,
palette,
false
)
.class(ferese_theme::controls::material_button_style_with_focus(
palette,
false,
material_opacity,
focus_visible
))
.id("ferese-modal-cancel".into())
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
ferese_theme::controls::text_button(
if pending { "Keep" } else { "Apply" },
self.render.font,
palette,
true
)
.class(ferese_theme::controls::button_style_with_focus(
palette,
true,
focus_visible
))
.id("ferese-display-apply".into())
.on_press_maybe(can_apply.then_some(cosmic::Action::App(apply))),
]
.spacing(10),
)
.id("ferese-display-modal-actions"),
rows.push(
self.render
.text(hint)
.size(12)
.class(theme::Text::Color(palette.muted)),
)
.push(
container(
row![
cosmic::iced::widget::Space::new().width(Length::Fill),
ferese_theme::controls::text_button(
if pending { "Revert" } else { "Cancel" },
self.render.font,
palette,
false
)
.class(ferese_theme::controls::material_button_style_with_focus(
palette,
false,
material_opacity,
focus_visible
))
.id("ferese-modal-cancel".into())
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
ferese_theme::controls::text_button(
if pending { "Keep" } else { "Apply" },
self.render.font,
palette,
true
)
.class(ferese_theme::controls::button_style_with_focus(
palette,
true,
focus_visible
))
.id("ferese-display-apply".into())
.on_press_maybe(can_apply.then_some(cosmic::Action::App(apply))),
]
.spacing(10),
)
.id("ferese-display-modal-actions"),
)
}
}
@@ -555,17 +593,26 @@ mod tests {
fn disabled_and_mirrored_external_monitors_keep_the_bar_trigger() {
for mirror in [false, true] {
let inventory = Inventory::parse(
json!([output(true, true, true, false), output(false, mirror, true, mirror)]),
json!([
output(true, true, true, false),
output(false, mirror, true, mirror)
]),
json!({}),
)
.unwrap();
assert!(inventory.external_connected());
assert_eq!(inventory.current(), if mirror { Mode::Mirror } else { Mode::Internal });
assert_eq!(
inventory.current(),
if mirror { Mode::Mirror } else { Mode::Internal }
);
assert!(Mode::ALL.into_iter().all(|mode| inventory.available(mode)));
}
let removed = Inventory::parse(
json!([output(true, true, true, false), output(false, false, false, false)]),
json!([
output(true, true, true, false),
output(false, false, false, false)
]),
json!({}),
)
.unwrap();
@@ -579,7 +626,10 @@ mod tests {
#[test]
fn confirmation_tracks_backend_state_and_profile_timeout() {
let mut model = Model::default();
let outputs = json!([output(true, true, true, false), output(false, true, true, false)]);
let outputs = json!([
output(true, true, true, false),
output(false, true, true, false)
]);
model.update(
Inventory::parse(
outputs.clone(),
@@ -591,7 +641,9 @@ mod tests {
assert_eq!(model.selected, Mode::Extend);
assert_eq!(model.inventory.as_ref().unwrap().timeout, 7);
let since = model.confirmation_since;
model.update(Inventory::parse(outputs.clone(), json!({"confirmation_pending": true})).unwrap());
model.update(
Inventory::parse(outputs.clone(), json!({"confirmation_pending": true})).unwrap(),
);
assert_eq!(
model.confirmation_since, since,
"inventory refresh must not restart the countdown"
+48 -16
View File
@@ -6,7 +6,11 @@ use serde::de::DeserializeOwned;
use serde_json::json;
/// Follow a versioned snapshot, clearing stale content when its connection ends.
pub(crate) fn stream<S>(get: &'static str, watch: &'static str, revision: fn(&S) -> u64) -> impl Stream<Item = Arc<S>>
pub(crate) fn stream<S>(
get: &'static str,
watch: &'static str,
revision: fn(&S) -> u64,
) -> impl Stream<Item = Arc<S>>
where
S: DeserializeOwned + Default + Send + Sync + 'static,
{
@@ -99,10 +103,14 @@ mod tests {
);
return;
}
check_stream::<ferese_ipc::media::Snapshot>("media-get", "media-watch", |snapshot| snapshot.revision);
check_stream::<ferese_ipc::activities::Snapshot>("activity-get", "activity-watch", |snapshot| {
check_stream::<ferese_ipc::media::Snapshot>("media-get", "media-watch", |snapshot| {
snapshot.revision
});
check_stream::<ferese_ipc::activities::Snapshot>(
"activity-get",
"activity-watch",
|snapshot| snapshot.revision,
);
}
fn check_stream<S>(get: &'static str, watch: &'static str, revision: fn(&S) -> u64)
@@ -122,32 +130,53 @@ mod tests {
match listener.accept() {
Ok((connection, _)) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(std::time::Instant::now() < deadline, "subscription did not reconnect");
assert!(
std::time::Instant::now() < deadline,
"subscription did not reconnect"
);
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("accept: {error}"),
}
};
connection.set_read_timeout(Some(Duration::from_secs(8))).unwrap();
connection.set_write_timeout(Some(Duration::from_secs(8))).unwrap();
connection
.set_read_timeout(Some(Duration::from_secs(8)))
.unwrap();
connection
.set_write_timeout(Some(Duration::from_secs(8)))
.unwrap();
let request: Request = read_frame(&mut connection).unwrap();
assert_eq!(request.command, get);
let mut snapshot = serde_json::to_value(S::default()).unwrap();
snapshot["revision"] = json!(initial);
write_frame(&mut connection, &Response::success(request.id, snapshot.clone())).unwrap();
write_frame(
&mut connection,
&Response::success(request.id, snapshot.clone()),
)
.unwrap();
let request: Request = read_frame(&mut connection).unwrap();
assert_eq!(request.command, watch);
assert_eq!(request.args["since"], initial);
if initial == 1 {
// Wait for delivery so watch-channel coalescing cannot skip this snapshot.
assert_eq!(consumed_receive.recv_timeout(Duration::from_secs(8)).unwrap(), 1);
assert_eq!(
consumed_receive
.recv_timeout(Duration::from_secs(8))
.unwrap(),
1
);
snapshot["revision"] = json!(2);
write_frame(&mut connection, &Response::success(request.id, snapshot)).unwrap();
let next: Request = read_frame(&mut connection).unwrap();
assert_eq!(next.command, watch);
assert_eq!(next.args["since"], 2);
assert_ne!(request.id, next.id);
assert_eq!(consumed_receive.recv_timeout(Duration::from_secs(8)).unwrap(), 2);
assert_eq!(
consumed_receive
.recv_timeout(Duration::from_secs(8))
.unwrap(),
2
);
// Dropping this connection must clear the old snapshot before retrying.
} else {
watch_send.send(()).unwrap();
@@ -166,18 +195,21 @@ mod tests {
.block_on(async {
let mut subscription = Box::pin(stream::<S>(get, watch, revision));
for expected in [1, 2, 0, 3] {
let snapshot = tokio::time::timeout(Duration::from_secs(8), subscription.next())
.await
.unwrap()
.unwrap();
let snapshot =
tokio::time::timeout(Duration::from_secs(8), subscription.next())
.await
.unwrap()
.unwrap();
assert_eq!(revision(&snapshot), expected);
if expected == 1 || expected == 2 {
consumed.send(expected).unwrap();
}
}
tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(2)).unwrap())
.await
.unwrap();
tokio::task::spawn_blocking(move || {
watch_receive.recv_timeout(Duration::from_secs(2)).unwrap()
})
.await
.unwrap();
drop(subscription);
});
server.join().unwrap();
+7 -2
View File
@@ -6,7 +6,8 @@ pub(super) struct Entry {
pub(super) fn load() -> Result<Vec<Entry>, String> {
let entries: Vec<Entry> =
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?).map_err(|error| error.to_string())?;
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?)
.map_err(|error| error.to_string())?;
if entries.len() > 256
|| entries
.iter()
@@ -18,7 +19,11 @@ pub(super) fn load() -> Result<Vec<Entry>, String> {
}
impl crate::FereseShell {
pub(super) fn load_guide(&mut self, manual: bool, output: Option<String>) -> cosmic::app::Task<crate::Message> {
pub(super) fn load_guide(
&mut self,
manual: bool,
output: Option<String>,
) -> cosmic::app::Task<crate::Message> {
let generation = self.guide_load.begin(manual);
cosmic::app::Task::perform(
async {
+16 -5
View File
@@ -1,6 +1,7 @@
use super::sources::{Matcher, ResultId, Row, Source, Target};
use fff_search::{
FilePicker, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
FilePicker, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser,
SharedFilePicker, SharedFrecency,
};
use std::sync::{Arc, OnceLock};
@@ -27,7 +28,8 @@ impl Files {
fn picker(&self) -> Result<&SharedFilePicker, &str> {
self.0
.get_or_init(|| {
let home = std::env::var_os("HOME").ok_or_else(|| "No home directory".to_string())?;
let home =
std::env::var_os("HOME").ok_or_else(|| "No home directory".to_string())?;
let home = std::fs::canonicalize(home).map_err(|e| e.to_string())?;
if home.parent().is_none() {
return Err("The filesystem root cannot be indexed".into());
@@ -55,7 +57,9 @@ impl Files {
pub(super) fn status(&self) -> Status {
match self.0.get() {
Some(Err(error)) => Status::Unavailable(format!("File search unavailable: {error}")),
Some(Ok(picker)) if !picker.wait_for_scan(std::time::Duration::ZERO) => Status::Indexing,
Some(Ok(picker)) if !picker.wait_for_scan(std::time::Duration::ZERO) => {
Status::Indexing
}
_ => Status::Ready,
}
}
@@ -94,7 +98,10 @@ impl Files {
None,
FuzzySearchOptions {
max_threads: 2,
pagination: PaginationArgs { offset: 0, limit: 100 },
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
..Default::default()
},
)
@@ -111,7 +118,11 @@ impl Files {
.into_iter()
.take_while(|_| !cancel.cancelled())
.map(|path| {
let title = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
let title = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let icon = Some(icons.file(&path));
let subtitle = format!("File · {}", path.parent().unwrap_or(&path).display());
let path: Arc<std::path::Path> = path.into();
+10 -4
View File
@@ -5,8 +5,9 @@ pub(super) fn run(target: Target, terminal: &[String]) -> Result<(), String> {
use sources::Action;
let mut command = match target {
Target::App { id, desktop } => {
let entry = freedesktop_desktop_entry::DesktopEntry::from_path(&desktop, None::<&[&str]>)
.map_err(|e| e.to_string())?;
let entry =
freedesktop_desktop_entry::DesktopEntry::from_path(&desktop, None::<&[&str]>)
.map_err(|e| e.to_string())?;
let result = if entry.terminal() {
let arguments = entry.parse_exec().map_err(|e| e.to_string())?;
let mut command = terminal_command(terminal)?;
@@ -16,7 +17,10 @@ pub(super) fn run(target: Target, terminal: &[String]) -> Result<(), String> {
}
spawn(&mut command)
} else {
open("gio", &[std::ffi::OsStr::new("launch"), desktop.as_os_str()])
open(
"gio",
&[std::ffi::OsStr::new("launch"), desktop.as_os_str()],
)
};
if result.is_ok()
&& let Err(error) = apps::record(&id)
@@ -25,7 +29,9 @@ pub(super) fn run(target: Target, terminal: &[String]) -> Result<(), String> {
}
return result;
}
Target::File(path) => return open("gio", &[std::ffi::OsStr::new("open"), path.as_os_str()]),
Target::File(path) => {
return open("gio", &[std::ffi::OsStr::new("open"), path.as_os_str()]);
}
Target::Action(action @ (Action::Light | Action::Dark | Action::Auto)) => {
return compositor_ipc::request("theme-set-mode",serde_json::json!({"mode": match action { Action::Light => "light", Action::Dark => "dark", _ => "auto" }})).map(|_|());
}
+75 -29
View File
@@ -112,7 +112,11 @@ impl Launcher {
}
}
impl FereseShell {
pub(super) fn attach_launcher_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
pub(super) fn attach_launcher_effects(
&mut self,
id: window::Id,
surface: &wl_surface::WlSurface,
) {
let Some(launcher) = self.launcher.as_mut().filter(|launcher| launcher.id == id) else {
return;
};
@@ -218,7 +222,9 @@ impl FereseShell {
.map(Arc::new)
.unwrap_or_else(|_| Arc::new(apps::Apps::empty()))
},
move |apps| cosmic::Action::App(super::Message::Launcher(Message::Loaded(id, apps))),
move |apps| {
cosmic::Action::App(super::Message::Launcher(Message::Loaded(id, apps)))
},
),
])
}
@@ -266,7 +272,9 @@ impl FereseShell {
let Some(launcher) = &self.launcher else {
return Subscription::none();
};
let interval = if launcher.progress.is_animating() || launcher.height.needs_update(launcher.target_height()) {
let interval = if launcher.progress.is_animating()
|| launcher.height.needs_update(launcher.target_height())
{
16
} else if launcher.category == Some(Category::Windows) && launcher.query.is_empty() {
500
@@ -275,7 +283,8 @@ impl FereseShell {
} else {
return Subscription::none();
};
cosmic::iced::time::every(Duration::from_millis(interval)).map(|_| super::Message::Launcher(Message::Tick))
cosmic::iced::time::every(Duration::from_millis(interval))
.map(|_| super::Message::Launcher(Message::Tick))
}
pub(super) fn refresh_launcher(&mut self) -> Task<super::Message> {
self.merge_launcher_windows(false)
@@ -286,18 +295,26 @@ impl FereseShell {
};
if launcher.closing
|| (launcher.query.is_empty() && launcher.category.is_none())
|| !launcher.category.is_none_or(|category| category == Category::Windows)
|| !launcher
.category
.is_none_or(|category| category == Category::Windows)
{
return Task::none();
}
let changed = launcher
.windows
.sync(&self.snapshot, launcher.apps.as_deref(), &launcher.query);
let changed =
launcher
.windows
.sync(&self.snapshot, launcher.apps.as_deref(), &launcher.query);
if !changed && !force {
return Task::none();
}
let selected = launcher.rows.get(launcher.selected).map(|row| row.id.clone());
launcher.rows.retain(|row| !matches!(row.target, Target::Window(_)));
let selected = launcher
.rows
.get(launcher.selected)
.map(|row| row.id.clone());
launcher
.rows
.retain(|row| !matches!(row.target, Target::Window(_)));
launcher.rows.extend(launcher.windows.rows().cloned());
sources::rank(&mut launcher.rows);
if launcher.category.is_none() {
@@ -312,7 +329,10 @@ impl FereseShell {
let Some(launcher) = &mut self.launcher else {
return Task::none();
};
if launcher.running || launcher.closing || (launcher.query.is_empty() && launcher.category.is_none()) {
if launcher.running
|| launcher.closing
|| (launcher.query.is_empty() && launcher.category.is_none())
{
return Task::none();
}
let Some(apps) = launcher.apps.clone() else {
@@ -362,14 +382,17 @@ impl FereseShell {
.unwrap_or_else(|e| (Vec::new(), files::Status::Unavailable(e.to_string())))
},
move |(rows, status)| {
cosmic::Action::App(super::Message::Launcher(Message::Results(id, revision, rows, status)))
cosmic::Action::App(super::Message::Launcher(Message::Results(
id, revision, rows, status,
)))
},
)
}
pub(super) fn update_launcher(&mut self, message: Message) -> Task<super::Message> {
if let Message::Finished(result) = message {
if let Err(error) = result {
self.notifications.service_error("Launcher action failed", &error);
self.notifications
.service_error("Launcher action failed", &error);
}
return Task::none();
}
@@ -379,11 +402,13 @@ impl FereseShell {
match message {
Message::Dismiss(id) if id == launcher.id => self.close_launcher(),
Message::AppsScrolled(id, first) if id == launcher.id && launcher.apps_browse() => {
launcher.apps_first_row = first.min(launcher.rows.len().div_ceil(4).saturating_sub(1));
launcher.apps_first_row =
first.min(launcher.rows.len().div_ceil(4).saturating_sub(1));
Task::none()
}
Message::Input(id, query) if id == launcher.id && !launcher.closing => {
launcher.ghost_visible = launcher.query.starts_with(&query) || query.starts_with(&launcher.query);
launcher.ghost_visible =
launcher.query.starts_with(&query) || query.starts_with(&launcher.query);
launcher.query = query;
launcher.alternatives = false;
launcher.changed();
@@ -399,7 +424,11 @@ impl FereseShell {
Task::batch([focus, self.query_launcher()])
}
Message::ActivateRow(id, row) if id == launcher.id && !launcher.closing => {
let Some(index) = launcher.rows.iter().position(|candidate| candidate.id == row) else {
let Some(index) = launcher
.rows
.iter()
.position(|candidate| candidate.id == row)
else {
return Task::none();
};
launcher.selected = index;
@@ -415,10 +444,12 @@ impl FereseShell {
if revision != launcher.revision {
return self.query_launcher();
}
let selected = launcher
.preferred_result
.take()
.or_else(|| launcher.rows.get(launcher.selected).map(|row| row.id.clone()));
let selected = launcher.preferred_result.take().or_else(|| {
launcher
.rows
.get(launcher.selected)
.map(|row| row.id.clone())
});
launcher.rows = rows;
launcher.file_status = status;
let task = self.merge_launcher_windows(true);
@@ -485,13 +516,18 @@ impl FereseShell {
_ => Task::none(),
}
}
pub(super) fn launcher_event(&mut self, event: &Event, id: window::Id) -> Option<Task<super::Message>> {
pub(super) fn launcher_event(
&mut self,
event: &Event,
id: window::Id,
) -> Option<Task<super::Message>> {
let launcher = self.launcher.as_mut().filter(|l| l.id == id)?;
if let Event::Window(window::Event::Opened { .. }) = event {
return Some(Task::batch([
cosmic::widget::text_input::focus(launcher.input.clone()),
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(super::Message::NativeSurface(id, surface))),
window::run(id, native_wayland_surface).map(move |surface| {
cosmic::Action::App(super::Message::NativeSurface(id, surface))
}),
]));
}
if matches!(
@@ -509,11 +545,16 @@ impl FereseShell {
if launcher.closing {
return Some(Task::none());
}
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_))) {
if matches!(
event,
Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_))
) {
launcher.ghost_visible = false;
}
let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key, modifiers, .. }) = event else {
let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key, modifiers, .. }) =
event
else {
return None;
};
if launcher.query.is_empty() && launcher.category.is_none() {
@@ -571,7 +612,8 @@ impl FereseShell {
}
Key::Named(Named::ArrowDown) => {
if launcher.alternatives || launcher.query.is_empty() {
launcher.selected = (launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
launcher.selected =
(launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
}
launcher.alternatives = true;
Task::none()
@@ -583,7 +625,8 @@ impl FereseShell {
Key::Named(Named::ArrowRight)
if launcher.query.is_empty() && launcher.category == Some(Category::Windows) =>
{
launcher.selected = (launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
launcher.selected =
(launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
Task::none()
}
Key::Named(Named::ArrowLeft)
@@ -624,7 +667,8 @@ impl FereseShell {
return Task::none();
}
if launcher.query.is_empty() && launcher.category.is_none() && launcher.category_focused {
let category = [Category::Apps, Category::Files, Category::Windows][launcher.selected.min(2)];
let category =
[Category::Apps, Category::Files, Category::Windows][launcher.selected.min(2)];
let id = launcher.id;
return self.update_launcher(Message::Browse(id, category));
}
@@ -681,7 +725,9 @@ impl FereseShell {
.await
.unwrap_or_else(|e| Err(e.to_string()))
},
move |result| cosmic::Action::App(super::Message::Launcher(Message::Launched(id, result))),
move |result| {
cosmic::Action::App(super::Message::Launcher(Message::Launched(id, result)))
},
)
}
}
+15 -4
View File
@@ -155,7 +155,12 @@ struct WindowEntry {
seen: bool,
}
impl Windows {
pub(super) fn sync(&mut self, snapshot: &ShellSnapshot, apps: Option<&super::apps::Apps>, query: &str) -> bool {
pub(super) fn sync(
&mut self,
snapshot: &ShellSnapshot,
apps: Option<&super::apps::Apps>,
query: &str,
) -> bool {
let query_changed = self.query != query || self.apps_ready != apps.is_some();
if self.query != query {
self.query.clear();
@@ -171,13 +176,17 @@ impl Windows {
.or_default()
.clone_from(&workspace.name);
}
self.workspaces.retain(|id, _| self.workspace_ids.contains(id));
self.workspaces
.retain(|id, _| self.workspace_ids.contains(id));
for entry in self.entries.values_mut() {
entry.seen = false;
}
let mut changed = query_changed;
for window in &snapshot.windows {
let name = self.workspaces.get(&window.workspace).map_or("?", String::as_str);
let name = self
.workspaces
.get(&window.workspace)
.map_or("?", String::as_str);
if let Some(entry) = self.entries.get_mut(&window.id) {
entry.seen = true;
if !query_changed
@@ -231,7 +240,9 @@ impl Windows {
changed
}
pub(super) fn rows(&self) -> impl Iterator<Item = &Row> {
self.entries.values().filter_map(|entry| entry.result.as_ref())
self.entries
.values()
.filter_map(|entry| entry.result.as_ref())
}
}
impl Source for Windows {
+28 -5
View File
@@ -42,7 +42,10 @@ fn escape_pops_one_layer_in_every_category() {
#[test]
fn typing_never_opens_alternates_and_empty_home_has_pills() {
let mut shell = shell();
let _ = shell.update_launcher(Message::Input(shell.launcher.as_ref().unwrap().id, "fire".into()));
let _ = shell.update_launcher(Message::Input(
shell.launcher.as_ref().unwrap().id,
"fire".into(),
));
let launcher = shell.launcher.as_mut().unwrap();
assert!(!launcher.alternatives);
assert_eq!(launcher.target_height(), 72.);
@@ -57,13 +60,28 @@ fn late_results_cannot_replace_a_new_query_or_invocation() {
let mut shell = shell();
let id = shell.launcher.as_ref().unwrap().id;
let _ = shell.update_launcher(Message::Input(id, "new".into()));
let _ = shell.update_launcher(Message::Results(id, 0, vec![row("old")], files::Status::Ready));
let _ = shell.update_launcher(Message::Results(
id,
0,
vec![row("old")],
files::Status::Ready,
));
assert!(shell.launcher.as_ref().unwrap().rows.is_empty());
let _ = shell.destroy_launcher();
let _ = shell.update_launcher(Message::Results(id, 1, vec![row("old")], files::Status::Ready));
let _ = shell.update_launcher(Message::Results(
id,
1,
vec![row("old")],
files::Status::Ready,
));
assert!(shell.launcher.is_none());
let _ = shell.toggle_launcher("test".into());
let _ = shell.update_launcher(Message::Results(id, 0, vec![row("old")], files::Status::Ready));
let _ = shell.update_launcher(Message::Results(
id,
0,
vec![row("old")],
files::Status::Ready,
));
assert!(shell.launcher.as_ref().unwrap().rows.is_empty());
}
#[test]
@@ -134,7 +152,12 @@ fn queries_are_coalesced_and_only_the_latest_result_is_applied() {
let _ = shell.update_launcher(Message::Input(id, "first".into()));
let _ = shell.update_launcher(Message::Input(id, "second".into()));
let revision = shell.launcher.as_ref().unwrap().revision;
let _ = shell.update_launcher(Message::Results(id, 0, vec![row("stale")], files::Status::Ready));
let _ = shell.update_launcher(Message::Results(
id,
0,
vec![row("stale")],
files::Status::Ready,
));
assert!(shell.launcher.as_ref().unwrap().running);
assert!(shell.launcher.as_ref().unwrap().rows.is_empty());
let _ = shell.update_launcher(Message::Results(
+30 -5
View File
@@ -35,7 +35,8 @@ impl Thumbnails {
_ => None,
})
});
self.images.retain(|id, _| self.visible.contains(&Some(*id)));
self.images
.retain(|id, _| self.visible.contains(&Some(*id)));
for id in self.visible.into_iter().flatten() {
if self.pending.len() < 3 && self.pending.insert(id) {
control.thumbnail(self.token, id);
@@ -45,11 +46,19 @@ impl Thumbnails {
}
}
impl FereseShell {
pub(crate) fn launcher_thumbnail(&mut self, token: u32, window: u64, image: Option<image::Handle>) {
pub(crate) fn launcher_thumbnail(
&mut self,
token: u32,
window: u64,
image: Option<image::Handle>,
) {
let Some(launcher) = &mut self.launcher else {
return;
};
if launcher.closing || launcher.thumbnails.token != token || !launcher.thumbnails.pending.remove(&window) {
if launcher.closing
|| launcher.thumbnails.token != token
|| !launcher.thumbnails.pending.remove(&window)
{
return;
}
if let Some(image) = image {
@@ -72,12 +81,28 @@ mod tests {
launcher.thumbnails.pending.insert(1);
let image = image::Handle::from_rgba(1, 1, vec![255, 0, 0, 255]);
shell.launcher_thumbnail(token.wrapping_add(1), 1, Some(image.clone()));
assert!(shell.launcher.as_ref().unwrap().thumbnails.images.is_empty());
assert!(
shell
.launcher
.as_ref()
.unwrap()
.thumbnails
.images
.is_empty()
);
shell.launcher_thumbnail(token, 1, Some(image.clone()));
assert_eq!(shell.launcher.as_ref().unwrap().thumbnails.images.len(), 1);
let _ = shell.destroy_launcher();
let _ = shell.toggle_launcher("test".into());
shell.launcher_thumbnail(token, 1, Some(image));
assert!(shell.launcher.as_ref().unwrap().thumbnails.images.is_empty());
assert!(
shell
.launcher
.as_ref()
.unwrap()
.thumbnails
.images
.is_empty()
);
}
}
+68 -19
View File
@@ -103,7 +103,11 @@ impl FereseShell {
let completion = launcher
.rows
.get(launcher.selected)
.filter(|_| !launcher.query.is_empty() && launcher.ghost_visible && launcher.query.chars().count() <= 28)
.filter(|_| {
!launcher.query.is_empty()
&& launcher.ghost_visible
&& launcher.query.chars().count() <= 28
})
.map(|row| {
if row
.title
@@ -168,7 +172,8 @@ impl FereseShell {
.spacing(16)
.align_y(cosmic::iced::Alignment::Center);
if let Some(handle) = search_match.and_then(|result| result.icon.clone()) {
input_row = input_row.push(cosmic::widget::icon::icon(handle).size(28).opacity(opacity));
input_row =
input_row.push(cosmic::widget::icon::icon(handle).size(28).opacity(opacity));
}
if launcher.query.is_empty() && launcher.category.is_none() {
let dark = self.config.theme.appearance == ferese_config::theme::Appearance::Dark;
@@ -225,7 +230,10 @@ impl FereseShell {
let input_content = if let Some(error) = launcher.status_message() {
column![
input_row,
self.render.text(error).size(12).class(theme::Text::Color(muted))
self.render
.text(error)
.size(12)
.class(theme::Text::Color(muted))
]
.spacing(2)
} else {
@@ -243,13 +251,22 @@ impl FereseShell {
];
if launcher.apps_browse() {
let total = launcher.rows.len().div_ceil(4);
let first = launcher.apps_first_row.min(total.saturating_sub(1)).saturating_sub(1);
let first = launcher
.apps_first_row
.min(total.saturating_sub(1))
.saturating_sub(1);
let end = (first + 6).min(total);
let mut grid = cosmic::iced::widget::keyed_column(Vec::<(usize, Ui<'_>)>::new());
if first > 0 {
grid = grid.push(usize::MAX, Space::new().height(first as f32 * 108.));
}
for (row_index, apps) in launcher.rows.chunks(4).enumerate().skip(first).take(end - first) {
for (row_index, apps) in launcher
.rows
.chunks(4)
.enumerate()
.skip(first)
.take(end - first)
{
let mut tiles = row![].spacing(12);
for (column_index, app) in apps.iter().enumerate() {
let glyph: Ui<'_> = match &app.icon {
@@ -278,11 +295,13 @@ impl FereseShell {
.width(Length::Fill)
.height(100)
.padding(12)
.class(button_style(if launcher.selected == row_index * 4 + column_index {
palette.accent.scale_alpha(opacity)
} else {
Color::TRANSPARENT
}))
.class(button_style(
if launcher.selected == row_index * 4 + column_index {
palette.accent.scale_alpha(opacity)
} else {
Color::TRANSPARENT
},
))
.on_press(message(Message::ActivateRow(id, app.id.clone()))),
);
}
@@ -295,14 +314,21 @@ impl FereseShell {
);
}
if end < total {
grid = grid.push(usize::MAX - 1, Space::new().height((total - end) as f32 * 108. - 8.));
grid = grid.push(
usize::MAX - 1,
Space::new().height((total - end) as f32 * 108. - 8.),
);
}
if launcher.rows.is_empty() {
grid = grid.push(
usize::MAX - 2,
container(
self.render
.text(if launcher.running { "Loading apps…" } else { "No apps" })
.text(if launcher.running {
"Loading apps…"
} else {
"No apps"
})
.size(14)
.class(theme::Text::Color(muted)),
)
@@ -337,7 +363,12 @@ impl FereseShell {
continue;
};
let window = self.snapshot.windows.iter().find(|w| w.id == id);
let workspace = window.and_then(|w| self.snapshot.workspaces.iter().find(|ws| ws.id == w.workspace));
let workspace = window.and_then(|w| {
self.snapshot
.workspaces
.iter()
.find(|ws| ws.id == w.workspace)
});
let thumbnail: Ui<'_> = match launcher.thumbnails.images.get(&id) {
Some(handle) => image(handle.clone())
.width(Length::Fill)
@@ -368,7 +399,11 @@ impl FereseShell {
.apps
.as_ref()
.and_then(|apps| apps.name(&w.app_id))
.unwrap_or(if w.app_id.is_empty() { &w.title } else { &w.app_id })
.unwrap_or(if w.app_id.is_empty() {
&w.title
} else {
&w.app_id
})
}))
.size(14)
.class(theme::Text::Color(text));
@@ -390,7 +425,10 @@ impl FereseShell {
.width(Length::Fill)
.padding(0)
.class(button_style(Color::TRANSPARENT))
.on_press(message(Message::ActivateRow(launcher.id, result.id.clone()))),
.on_press(message(Message::ActivateRow(
launcher.id,
result.id.clone(),
))),
);
}
if launcher.rows.is_empty() {
@@ -402,7 +440,9 @@ impl FereseShell {
);
}
body = body.push(container(cards).padding([0, 16]));
} else if launcher.alternatives || (launcher.query.is_empty() && launcher.category.is_some()) {
} else if launcher.alternatives
|| (launcher.query.is_empty() && launcher.category.is_some())
{
let start = launcher.selected.saturating_sub(4);
let mut rows = column![].spacing(0);
for (index, result) in launcher.rows.iter().enumerate().skip(start).take(5) {
@@ -437,11 +477,17 @@ impl FereseShell {
]
.spacing(3)
.width(Length::Fill);
let mut content = row![glyph, labels].spacing(12).align_y(cosmic::iced::Alignment::Center);
let mut content = row![glyph, labels]
.spacing(12)
.align_y(cosmic::iced::Alignment::Center);
if index == launcher.selected {
content = content.push(
self.render
.text(if result.requires_alt() { "Alt+Enter" } else { "Enter" })
.text(if result.requires_alt() {
"Alt+Enter"
} else {
"Enter"
})
.size(12)
.class(theme::Text::Color(muted)),
);
@@ -463,7 +509,10 @@ impl FereseShell {
},
18.,
))
.on_press(message(Message::ActivateRow(launcher.id, result.id.clone()))),
.on_press(message(Message::ActivateRow(
launcher.id,
result.id.clone(),
))),
);
}
if launcher.rows.is_empty() {
+271 -94
View File
@@ -39,11 +39,12 @@ use cosmic::iced::platform_specific::runtime::wayland::layer_surface::{
IcedMargin, IcedOutput, SctkLayerSurfaceSettings,
};
use cosmic::iced::platform_specific::shell::commands::layer_surface::{
Anchor, KeyboardInteractivity, Layer, destroy_layer_surface, set_anchor, set_exclusive_zone, set_input_zone,
set_margin, set_size,
Anchor, KeyboardInteractivity, Layer, destroy_layer_surface, set_anchor, set_exclusive_zone,
set_input_zone, set_margin, set_size,
};
use cosmic::iced::{
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, alignment, event, window,
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, alignment, event,
window,
};
use cosmic::widget::{button, container, icon, image, row};
use cosmic::{Element, theme};
@@ -124,9 +125,12 @@ fn main() -> cosmic::iced::Result {
let wallpaper = config.wallpaper.path.clone().map(|path| {
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let result = cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path))
.map(|pixels| image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw()))
.map_err(|error| format!("{error:?}"));
let result =
cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path))
.map(|pixels| {
image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw())
})
.map_err(|error| format!("{error:?}"));
let _ = sender.send(result);
});
receiver
@@ -143,7 +147,8 @@ fn main() -> cosmic::iced::Result {
cosmic::app::run::<FereseShell>(settings, (config, wallpaper, startup_error, theme_snapshot))
}
type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
type WallpaperLoad =
cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
struct FereseShell {
core: Core,
@@ -210,7 +215,11 @@ enum Message {
HubWorkspaceHover(window::Id, Option<u64>),
HubWorkspaceSelect(window::Id, u64),
HubWorkspaceMove(window::Id, u64, u64),
HubWorkspaceFocus(window::Id, Option<u64>, Option<cosmic::iced::keyboard::key::Named>),
HubWorkspaceFocus(
window::Id,
Option<u64>,
Option<cosmic::iced::keyboard::key::Named>,
),
HubToastExpired(window::Id, Instant),
HubWorkspaceFeedbackExpired(window::Id, Instant),
HubIgnore,
@@ -223,7 +232,10 @@ enum Message {
ThemeModeSet(Result<(), String>),
WallpaperLoaded(Result<image::Handle, String>),
Event(Event, window::Id),
NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>),
NativeSurface(
window::Id,
Result<(Connection, wl_surface::WlSurface), String>,
),
ClockChanged(String),
RecorderEvent(recording::Event),
RecorderElapsed,
@@ -233,7 +245,10 @@ enum Message {
MediaTick,
ActivitiesUpdated(std::sync::Arc<ferese_ipc::activities::Snapshot>),
ActivityTick,
Pomodoro(ferese_ipc::activities::PomodoroAction, Option<ferese_activities::Id>),
Pomodoro(
ferese_ipc::activities::PomodoroAction,
Option<ferese_activities::Id>,
),
PomodoroResult(Result<(), String>),
ActivityHover(window::Id, bool),
ActivityActivate(window::Id, ferese_activities::Id),
@@ -324,8 +339,13 @@ impl cosmic::Application for FereseShell {
.ok();
// The compositor reports the complete configuration error through its
// accepted-theme snapshot. Use the local warning only when standalone.
let pending_config_error = if control.is_none() { startup_error } else { None };
let mut notification_center = notifications::Center::new(config.notifications.clone(), config.animations);
let pending_config_error = if control.is_none() {
startup_error
} else {
None
};
let mut notification_center =
notifications::Center::new(config.notifications.clone(), config.animations);
notification_center.activity_bridge = config.activities.sources.notification_bridge;
let app = Self {
core,
@@ -366,7 +386,9 @@ impl cosmic::Application for FereseShell {
let wallpaper_task = match wallpaper {
Some(receiver) => cosmic::task::future(async move {
cosmic::Action::App(Message::WallpaperLoaded(
receiver.await.unwrap_or_else(|error| Err(error.to_string())),
receiver
.await
.unwrap_or_else(|error| Err(error.to_string())),
))
}),
None => Task::none(),
@@ -378,46 +400,66 @@ impl cosmic::Application for FereseShell {
fn subscription(&self) -> Subscription<Self::Message> {
Subscription::batch([
self.launcher_subscription(),
self.control.as_ref().map_or_else(Subscription::none, |control| {
control.subscription().map(|_| Message::ControlReady)
}),
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
self.notifications.subscription().map(Message::NotificationEvent),
self.control
.as_ref()
.map_or_else(Subscription::none, |control| {
control.subscription().map(|_| Message::ControlReady)
}),
ferese_theme_client::service::subscription()
.map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
self.notifications
.subscription()
.map(Message::NotificationEvent),
self.notifications
.tick_subscription()
.map(|_| Message::NotificationTick),
event::listen_with(|event, status, id| match &event {
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
key:
cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
modifiers,
..
}) if status == cosmic::iced::event::Status::Captured => {
Some(Message::LauncherEnter(id, modifiers.alt()))
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING
.load(Ordering::Relaxed)
.then_some(Message::Event(event, id)),
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => {
EFFECT_FRAME_PENDING
.load(Ordering::Relaxed)
.then_some(Message::Event(event, id))
}
Event::Keyboard(_)
| Event::Mouse(
cosmic::iced::mouse::Event::ButtonPressed(_)
| cosmic::iced::mouse::Event::CursorLeft
| cosmic::iced::mouse::Event::CursorEntered,
)
| Event::Window(window::Event::Opened { .. } | window::Event::Closed | window::Event::Rescaled(_))
| Event::Window(
window::Event::Opened { .. }
| window::Event::Closed
| window::Event::Rescaled(_),
)
| Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..),
wayland::Event::Popup(..)
| wayland::Event::Layer(..)
| wayland::Event::Output(..),
)) => Some(Message::Event(event, id)),
_ => None,
}),
clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged),
clock::subscription(self.outputs.iter().any(|output| !output.hidden))
.map(Message::ClockChanged),
activities::subscription::subscription().map(Message::ActivitiesUpdated),
self.activity_tick_subscription().map(|_| Message::ActivityTick),
self.activity_tick_subscription()
.map(|_| Message::ActivityTick),
self.media.subscription().map(Message::MediaUpdated),
self.media_tick_subscription().map(|_| Message::MediaTick),
self.media.art.subscription().map(Message::MediaArt),
self.recorder.subscription().map(Message::RecorderEvent),
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
self.status_service.subscription().map(Message::StatusUpdated),
self.recorder
.elapsed_subscription()
.map(|_| Message::RecorderElapsed),
self.status_service
.subscription()
.map(Message::StatusUpdated),
])
}
@@ -437,10 +479,16 @@ impl cosmic::Application for FereseShell {
Message::HubOverview(id) => self.open_hub_overview(id),
Message::HubWorkspaceHover(id, workspace) => self.hub_workspace_hover(id, workspace),
Message::HubWorkspaceSelect(id, workspace) => self.hub_workspace_select(id, workspace),
Message::HubWorkspaceMove(id, window, workspace) => self.hub_workspace_move(id, window, workspace),
Message::HubWorkspaceFocus(id, focused, key) => self.hub_workspace_focus(id, focused, key),
Message::HubWorkspaceMove(id, window, workspace) => {
self.hub_workspace_move(id, window, workspace)
}
Message::HubWorkspaceFocus(id, focused, key) => {
self.hub_workspace_focus(id, focused, key)
}
Message::HubToastExpired(id, revision) => self.hub_toast_expired(id, revision),
Message::HubWorkspaceFeedbackExpired(id, revision) => self.hub_workspace_feedback_expired(id, revision),
Message::HubWorkspaceFeedbackExpired(id, revision) => {
self.hub_workspace_feedback_expired(id, revision)
}
Message::HubIgnore => Task::none(),
Message::PanelResolved(id, resolution) => {
let allocation_changed = self
@@ -456,7 +504,8 @@ impl cosmic::Application for FereseShell {
let close_menu = self.menu.as_ref().is_some_and(|menu| {
menu.anchor.parent == id
&& menu.anchor.item.is_some()
&& ((menu.kind == status_ui::Menu::Overflow && resolution.overflow.is_empty())
&& ((menu.kind == status_ui::Menu::Overflow
&& resolution.overflow.is_empty())
|| allocation_changed)
});
if let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) {
@@ -487,7 +536,11 @@ impl cosmic::Application for FereseShell {
output.bar_regions = regions;
set_input_zone(
id,
bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions),
bar::input_region(
ferese_panel::PanelSurface::None,
output.hidden,
&output.bar_regions,
),
)
}
@@ -522,16 +575,14 @@ impl cosmic::Application for FereseShell {
self.sync_hub_activity()
}
Message::ActivityActivate(id, activity) => self.activate_activity(id, activity),
Message::ActivityNavigation(id, focused, key) => {
self.activity_navigation(id, focused, key).unwrap_or_else(Task::none)
}
Message::ActivityNavigation(id, focused, key) => self
.activity_navigation(id, focused, key)
.unwrap_or_else(Task::none),
Message::MediaUpdated(snapshot) => {
self.media.receive(snapshot);
self.refresh_media_art(
self.menu
.as_ref()
.is_some_and(|menu| menu.kind == status_ui::Menu::Media && !menu.motion.closing()),
);
self.refresh_media_art(self.menu.as_ref().is_some_and(|menu| {
menu.kind == status_ui::Menu::Media && !menu.motion.closing()
}));
Task::none()
}
Message::MediaAction(args) => self.media.command(args),
@@ -554,8 +605,13 @@ impl cosmic::Application for FereseShell {
};
let mut args = self.media.action("seek");
args["position_us"] = serde_json::json!(position);
args["track_id"] =
serde_json::json!(self.media.snapshot.selected.as_ref().and_then(|p| p.track_id.clone()));
args["track_id"] = serde_json::json!(
self.media
.snapshot
.selected
.as_ref()
.and_then(|p| p.track_id.clone())
);
self.media.command(args)
}
Message::MediaArt(art) => {
@@ -577,7 +633,8 @@ impl cosmic::Application for FereseShell {
}
Message::ThemeMode(mode) => cosmic::task::future(async move {
let result = tokio::task::spawn_blocking(move || {
let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
let mut connection =
ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
connection
.call("theme-set-mode", serde_json::json!({"mode": mode}))
.map(|_| ())
@@ -602,7 +659,9 @@ impl cosmic::Application for FereseShell {
}
Message::NativeSurface(id, result) => {
match result {
Ok((_connection, surface)) if self.outputs.iter().any(|output| output.bar == id) => {
Ok((_connection, surface))
if self.outputs.iter().any(|output| output.bar == id) =>
{
self.attach_effects(id, &surface)
}
Ok((_connection, surface)) => {
@@ -612,9 +671,16 @@ impl cosmic::Application for FereseShell {
&& entry.id == id
&& entry.effects.is_none()
{
match EffectsBinding::attach_role(&mut self.effects_context, &surface, None, 1.0) {
match EffectsBinding::attach_role(
&mut self.effects_context,
&surface,
None,
1.0,
) {
Ok(binding) => entry.effects = Some(binding),
Err(error) => eprintln!("ferese-shell: notification effects unavailable: {error}"),
Err(error) => eprintln!(
"ferese-shell: notification effects unavailable: {error}"
),
}
}
@@ -786,10 +852,14 @@ impl cosmic::Application for FereseShell {
Task::none()
}
Message::OpenDisplays(output) => self.open_display_modal(output.as_deref()),
Message::DisplaysLoaded(generation, result) => self.finish_display_inventory(generation, result),
Message::DisplaysLoaded(generation, result) => {
self.finish_display_inventory(generation, result)
}
Message::SelectDisplayMode(mode) => self.select_display_mode(mode, true),
Message::ApplyDisplayMode => self.apply_display_mode(),
Message::DisplayModeCompleted(serial, close, result) => self.finish_display_mode(serial, close, result),
Message::DisplayModeCompleted(serial, close, result) => {
self.finish_display_mode(serial, close, result)
}
Message::KeepDisplayMode => self.confirm_display_mode(true),
Message::RevertDisplayMode => self.confirm_display_mode(false),
Message::ExecutePower => self.execute_system_modal(),
@@ -810,7 +880,9 @@ impl cosmic::Application for FereseShell {
}
Message::CommitNightLightTemperature => {
if let Some(value) = self.night_light_temperature.take() {
return self.update(Message::Control(status::Action::NightLightTemperature(value)));
return self.update(Message::Control(status::Action::NightLightTemperature(
value,
)));
}
Task::none()
}
@@ -820,7 +892,10 @@ impl cosmic::Application for FereseShell {
}
if let status::Action::PowerProfile(profile) = action {
self.status_error = self.status_service.send(status::Action::PowerProfile(profile)).err();
self.status_error = self
.status_service
.send(status::Action::PowerProfile(profile))
.err();
if self.status_error.is_none()
&& let Some(profiles) = &mut self.status.power_profiles
@@ -840,7 +915,10 @@ impl cosmic::Application for FereseShell {
return self.sync_hub_activity();
}
status::Action::Notifications => {
return Task::batch([self.close_menu(), self.toggle_notification_history()]);
return Task::batch([
self.close_menu(),
self.toggle_notification_history(),
]);
}
_ => {}
}
@@ -852,7 +930,10 @@ impl cosmic::Application for FereseShell {
self.optimistic_status(&action);
}
if matches!(action, status::Action::Notifications | status::Action::Settings) {
if matches!(
action,
status::Action::Notifications | status::Action::Settings
) {
return self.close_menu();
}
@@ -905,7 +986,9 @@ impl cosmic::Application for FereseShell {
self.snapshot = snapshot;
reload_task = Task::batch([reload_task, self.refresh_launcher()]);
let mode = self.binding_mode_transition(mode_was_active);
let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
let feedback = self.sync_hub_workspace_feedback(
overview_was_active || self.overview_active,
);
let mut tasks = vec![
reload_task,
mode,
@@ -930,7 +1013,10 @@ impl cosmic::Application for FereseShell {
tasks.push(set_exclusive_zone(
entry.bar,
entry.runtime.panel(&self.config.panels[0]).exclusive_zone(hidden),
entry
.runtime
.panel(&self.config.panels[0])
.exclusive_zone(hidden),
));
entry.hidden = hidden;
if hidden && entry.runtime.hub.is_some() {
@@ -944,7 +1030,11 @@ impl cosmic::Application for FereseShell {
}
tasks.push(set_input_zone(
entry.bar,
bar::input_region(ferese_panel::PanelSurface::None, hidden, &entry.bar_regions),
bar::input_region(
ferese_panel::PanelSurface::None,
hidden,
&entry.bar_regions,
),
));
}
for id in suspended_hubs {
@@ -971,14 +1061,26 @@ impl cosmic::Application for FereseShell {
self.launcher_thumbnail(token, window, image);
Task::none()
}
control::ControlCommand::ToggleLauncher(output) => self.toggle_launcher(output),
control::ControlCommand::ToggleDisplays(output) => self.open_display_modal(Some(&output)),
control::ControlCommand::MonitorsChanged => self.load_display_inventory(),
control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output),
control::ControlCommand::CancelLogout(serial) => self.cancel_logout_modal(serial),
control::ControlCommand::Logout(serial, output) => {
self.open_system_modal(system_modal::PowerAction::Logout(serial), Some(&output))
control::ControlCommand::ToggleLauncher(output) => {
self.toggle_launcher(output)
}
control::ControlCommand::ToggleDisplays(output) => {
self.open_display_modal(Some(&output))
}
control::ControlCommand::MonitorsChanged => {
self.load_display_inventory()
}
control::ControlCommand::ToggleGuide(output) => {
self.toggle_guide(output)
}
control::ControlCommand::CancelLogout(serial) => {
self.cancel_logout_modal(serial)
}
control::ControlCommand::Logout(serial, output) => self
.open_system_modal(
system_modal::PowerAction::Logout(serial),
Some(&output),
),
};
reload_task = Task::batch([reload_task, task]);
}
@@ -995,7 +1097,9 @@ impl cosmic::Application for FereseShell {
self.hover_workspace(bar, workspace, target, entered)
}
Message::WorkspaceTooltipDelay(serial) => self.locate_workspace_tooltip(serial),
Message::WorkspaceTooltipBounds(serial, bounds) => self.show_workspace_tooltip(serial, bounds),
Message::WorkspaceTooltipBounds(serial, bounds) => {
self.show_workspace_tooltip(serial, bounds)
}
Message::WorkspaceIconsLoaded(result) => self.workspace_icons_loaded(result),
Message::ToggleOverview => {
self.overview_active = !self.overview_active;
@@ -1082,9 +1186,11 @@ impl FereseShell {
self.config = config;
if composition_changed || geometry_changed {
for entry in &mut self.outputs {
entry
.runtime
.reset(&self.config.panels[0], &self.snapshot, entry.name.as_deref());
entry.runtime.reset(
&self.config.panels[0],
&self.snapshot,
entry.name.as_deref(),
);
}
} else {
for entry in &mut self.outputs {
@@ -1101,14 +1207,21 @@ impl FereseShell {
// publishes its replacement through PanelResolved.
tasks.push(set_input_zone(
output.bar,
bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions),
bar::input_region(
ferese_panel::PanelSurface::None,
output.hidden,
&output.bar_regions,
),
));
}
}
if !self.guide_load.manual
&& !self.config.status.keybinding_guide
&& self.system_modal.as_ref().is_some_and(|modal| modal.is_guide())
&& self
.system_modal
.as_ref()
.is_some_and(|modal| modal.is_guide())
{
tasks.push(self.destroy_system_modal(false));
}
@@ -1117,11 +1230,20 @@ impl FereseShell {
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
}
for entry in self.outputs.iter().filter(|_| geometry_changed || composition_changed) {
for entry in self
.outputs
.iter()
.filter(|_| geometry_changed || composition_changed)
{
let panel = entry.runtime.panel(&self.config.panels[0]);
let geometry = panel.resolved_geometry();
let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
let hub_size = entry.runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
let (anchor, _) =
surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
let hub_size = entry
.runtime
.hub
.as_ref()
.map(|hub| hub.borrow().allocation);
tasks.push(set_anchor(
entry.bar,
if hub_size.is_some() {
@@ -1130,9 +1252,10 @@ impl FereseShell {
anchor | Anchor::LEFT | Anchor::RIGHT
},
));
let (width, height) = hub_size.map_or((None, Some(geometry.height.round() as u32)), |size| {
(Some(size[0]), Some(size[1]))
});
let (width, height) = hub_size
.map_or((None, Some(geometry.height.round() as u32)), |size| {
(Some(size[0]), Some(size[1]))
});
tasks.push(set_size(entry.bar, width, height));
tasks.push(
cosmic::iced::platform_specific::shell::commands::layer_surface::set_keyboard_interactivity(
@@ -1140,14 +1263,19 @@ impl FereseShell {
KeyboardInteractivity::None,
),
);
tasks.push(set_exclusive_zone(entry.bar, panel.exclusive_zone(entry.hidden)));
tasks.push(set_exclusive_zone(
entry.bar,
panel.exclusive_zone(entry.hidden),
));
}
let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect();
for bar in bars {
tasks.push(self.update_panel_margin(
bar,
composition_changed || edge_changed || old_geometry.edge_margin != geometry.edge_margin,
composition_changed
|| edge_changed
|| old_geometry.edge_margin != geometry.edge_margin,
));
}
if geometry_changed || composition_changed {
@@ -1185,7 +1313,8 @@ impl FereseShell {
return Task::none();
}
entry.bar_margin_horizontal = margin;
let (_, margin) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, margin);
let (_, margin) =
surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, margin);
set_margin(bar, margin.top, margin.right, margin.bottom, margin.left)
}
}
@@ -1216,7 +1345,10 @@ mod tests {
FereseShell {
core: Core::default(),
theme_snapshot: ferese_theme_client::service::fallback(),
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
notifications: notifications::Center::new(
config.notifications.clone(),
config.animations,
),
notification_surface: None,
status_service: status::Service::start(None),
status: Default::default(),
@@ -1247,7 +1379,11 @@ mod tests {
overview_active: false,
clock: String::new(),
outputs: vec![OutputSurfaces {
runtime: panel_runtime::Runtime::new(&panel::Panel::default(), &ShellSnapshot::default(), None),
runtime: panel_runtime::Runtime::new(
&panel::Panel::default(),
&ShellSnapshot::default(),
None,
),
scale: 1.,
output,
name: Some("test".into()),
@@ -1273,10 +1409,16 @@ mod tests {
for gap in [0, 12] {
let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32);
assert_eq!(anchor, Anchor::TOP);
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (gap, 32, 0, 32));
assert_eq!(
(margin.top, margin.right, margin.bottom, margin.left),
(gap, 32, 0, 32)
);
let (anchor, margin) = surfaces::panel_placement(Edge::Bottom, gap, 32);
assert_eq!(anchor, Anchor::BOTTOM);
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 32, gap, 32));
assert_eq!(
(margin.top, margin.right, margin.bottom, margin.left),
(0, 32, gap, 32)
);
}
}
@@ -1369,7 +1511,10 @@ mod tests {
assert_eq!(shell.config.theme_mode, snapshot.mode);
assert_eq!(shell.config.theme.appearance, snapshot.presented.appearance);
assert_eq!(shell.config.theme.material_radius, 23.);
assert_eq!(shell.config.font_family.as_deref(), Some("Reload test font"));
assert_eq!(
shell.config.font_family.as_deref(),
Some("Reload test font")
);
assert_eq!(shell.theme_snapshot, snapshot);
}
@@ -1403,7 +1548,10 @@ mod tests {
let edit = field;
let mut document = ferese_config::Document::parse("").unwrap();
document
.set("panels", serde_json::to_value(&shell.config.panels).unwrap())
.set(
"panels",
serde_json::to_value(&shell.config.panels).unwrap(),
)
.unwrap();
document.set(&format!("panels.0.{field}"), value).unwrap();
let source = document.to_string();
@@ -1427,12 +1575,21 @@ mod tests {
minimum_width: 900.,
..Default::default()
};
drop(shell.update(Message::PanelResolved(shell.outputs[0].bar, replacement.clone())));
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement));
drop(shell.update(Message::PanelResolved(
shell.outputs[0].bar,
replacement.clone(),
)));
assert_eq!(
shell.outputs[0].panel_resolution.as_ref(),
Some(&replacement)
);
assert!(shell.outputs[0].bar_margin_horizontal < 240);
let before = shell.outputs[0].bar_margin_horizontal;
drop(shell.reload_config("panel broken {".into()));
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement));
assert_eq!(
shell.outputs[0].panel_resolution.as_ref(),
Some(&replacement)
);
assert_eq!(shell.outputs[0].bar_margin_horizontal, before);
}
@@ -1507,7 +1664,10 @@ mod tests {
"unchanged allocation must preserve the popup"
);
drop(shell.update(Message::PanelResolved(bar, after.clone())));
assert!(shell.menu.is_none(), "stale {kind:?} popup must be destroyed");
assert!(
shell.menu.is_none(),
"stale {kind:?} popup must be destroyed"
);
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(after));
}
}
@@ -1569,9 +1729,16 @@ mod tests {
text_muted: [255, 255, 255, 255],
..theme
};
let before = ferese_theme::panel::group_border(color(theme.border), theme.material_radius.min(16.), true);
let after =
ferese_theme::panel::group_border(color(contrasted.border), contrasted.material_radius.min(16.), true);
let before = ferese_theme::panel::group_border(
color(theme.border),
theme.material_radius.min(16.),
true,
);
let after = ferese_theme::panel::group_border(
color(contrasted.border),
contrasted.material_radius.min(16.),
true,
);
assert_eq!(before.border, after.border);
assert_eq!(before.background, after.background);
assert_eq!(before.border.color, color(theme.border));
@@ -1593,11 +1760,18 @@ mod tests {
#[test]
fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() {
let theme = ShellTheme::default();
assert_eq!(shell_surface_style(theme).background_color, Color::TRANSPARENT);
assert_eq!(
shell_surface_style(theme).background_color,
Color::TRANSPARENT
);
let background = color(theme.for_bar().bar_background);
assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20);
assert!(background.a > 0.9);
assert!(theme.bar_text_primary[..3].iter().all(|channel| *channel > 220));
assert!(
theme.bar_text_primary[..3]
.iter()
.all(|channel| *channel > 220)
);
}
#[test]
@@ -1650,7 +1824,10 @@ mod tests {
#[test]
fn bar_title_tracks_focus_and_the_outputs_active_workspace() {
let mut snapshot = snapshot_with_fullscreen_window(7);
assert_eq!(focused_bar_title(&snapshot, snapshot.outputs.first()), "Test");
assert_eq!(
focused_bar_title(&snapshot, snapshot.outputs.first()),
"Test"
);
snapshot.windows[0].focused = false;
assert_eq!(focused_bar_title(&snapshot, snapshot.outputs.first()), "");
snapshot.windows[0].focused = true;
+26 -11
View File
@@ -56,7 +56,8 @@ impl Model {
pub fn tick_subscription(&self, visible: bool, on_battery: bool) -> Subscription<()> {
if visible && needs_progress(&self.snapshot) {
cosmic::iced::time::every(Duration::from_secs(if on_battery { 3 } else { 1 })).map(|_| ())
cosmic::iced::time::every(Duration::from_secs(if on_battery { 3 } else { 1 }))
.map(|_| ())
} else {
Subscription::none()
}
@@ -75,9 +76,18 @@ impl Model {
}
pub fn refresh_art(&mut self, visible: bool) {
let player = if visible { self.snapshot.selected.as_ref() } else { None };
let player = if visible {
self.snapshot.selected.as_ref()
} else {
None
};
self.art.request(player);
if visible && self.artwork.as_ref().is_some_and(|art| !self.art.accepts(art)) {
if visible
&& self
.artwork
.as_ref()
.is_some_and(|art| !self.art.accepts(art))
{
self.artwork = None;
}
}
@@ -91,7 +101,9 @@ impl Model {
&& pending["owner"] == args["owner"]
{
pending["delta"] = json!(
(pending["delta"].as_f64().unwrap_or(0.0) + args["delta"].as_f64().unwrap_or(0.0)).clamp(-1.0, 1.0)
(pending["delta"].as_f64().unwrap_or(0.0)
+ args["delta"].as_f64().unwrap_or(0.0))
.clamp(-1.0, 1.0)
);
} else if self.pending.len() < 8 {
self.pending.push_back(args);
@@ -106,7 +118,8 @@ impl Model {
self.error = None;
cosmic::task::future(async move {
let result = tokio::task::spawn_blocking(move || {
let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
let mut connection =
ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
connection.call("media-action", args).map(|_| ())
})
.await
@@ -127,7 +140,9 @@ impl Model {
fn needs_progress(snapshot: &Snapshot) -> bool {
snapshot.selected.as_ref().is_some_and(|player| {
player.status == Playback::Playing && player.position_us.is_some() && player.length_us.is_some()
player.status == Playback::Playing
&& player.position_us.is_some()
&& player.length_us.is_some()
})
}
@@ -141,14 +156,14 @@ impl FereseShell {
let hub_visible = self.outputs.iter().any(|entry| {
!entry.hidden
&& entry.runtime.hub.as_ref().is_some_and(|hub| {
hub.borrow().hub.mode() == ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Media)
hub.borrow().hub.mode()
== ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Media)
})
});
let visible = (hub_visible
|| self
.menu
.as_ref()
.is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()))
|| self.menu.as_ref().is_some_and(|menu| {
menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()
}))
&& self.outputs.iter().any(|output| !output.hidden);
let on_battery = self
.status
+8 -2
View File
@@ -304,11 +304,17 @@ mod tests {
None
})
});
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), "first");
assert_eq!(
starts.recv_timeout(Duration::from_secs(2)).unwrap(),
"first"
);
queue.0.lock().unwrap().pending = Some(key("second"));
queue.0.lock().unwrap().pending = Some(key("latest"));
resume.send(()).unwrap();
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), "latest");
assert_eq!(
starts.recv_timeout(Duration::from_secs(2)).unwrap(),
"latest"
);
queue.0.lock().unwrap().stopped = true;
queue.1.notify_one();
worker.join().unwrap();
+3 -1
View File
@@ -8,5 +8,7 @@ pub(super) fn subscription() -> Subscription<Arc<Snapshot>> {
}
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
crate::ipc_subscription::stream("media-get", "media-watch", |snapshot: &Snapshot| snapshot.revision)
crate::ipc_subscription::stream("media-get", "media-watch", |snapshot: &Snapshot| {
snapshot.revision
})
}
+150 -56
View File
@@ -1,7 +1,9 @@
mod frames;
pub(crate) use frames::{ContentMeasurement, frame_driven, geometry_driven};
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::advanced::{
Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget,
};
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
@@ -59,9 +61,13 @@ impl PopupMotion {
value.snap();
return value;
}
let spring = self.settings.spring_config().expect("validated motion settings");
let spring = self
.settings
.spring_config()
.expect("validated motion settings");
value.advance(
now.saturating_duration_since(started).mul_f64(self.settings.speed),
now.saturating_duration_since(started)
.mul_f64(self.settings.speed),
SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
@@ -113,7 +119,14 @@ pub(crate) fn button<'a, M: Clone + 'a>(
opacity: f32,
) -> Element<'a, M> {
let paint = move |progress: f32, pressed: bool| {
ferese_theme::controls::shell_button(foreground, selected, opacity, render.radius(14.), progress, pressed)
ferese_theme::controls::shell_button(
foreground,
selected,
opacity,
render.radius(14.),
progress,
pressed,
)
};
painted_button(render, button, paint)
}
@@ -127,8 +140,14 @@ pub(crate) fn circular_button<'a, M: Clone + 'a>(
diameter: f32,
) -> Element<'a, M> {
let paint = move |progress: f32, pressed: bool| {
let mut style =
ferese_theme::controls::shell_button(foreground, false, opacity, diameter / 2.0, progress, pressed);
let mut style = ferese_theme::controls::shell_button(
foreground,
false,
opacity,
diameter / 2.0,
progress,
pressed,
);
style.shape = Some(cosmic::iced::border::Shape::Circular);
if let Some(fill) = fill {
style.background = Some(
@@ -177,7 +196,12 @@ struct HoverState {
}
impl HoverState {
fn advance(&mut self, target: f32, now: std::time::Instant, duration: std::time::Duration) -> bool {
fn advance(
&mut self,
target: f32,
now: std::time::Instant,
duration: std::time::Duration,
) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
@@ -187,7 +211,8 @@ impl HoverState {
1.0
} else {
self.started.map_or(1.0, |start| {
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32()).min(1.0)
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
.min(1.0)
})
};
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
@@ -212,7 +237,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn tag(&self) -> widget::tree::Tag {
@@ -289,10 +316,17 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.progress.set(tree.state.downcast_ref::<HoverState>().current);
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
self.progress
.set(tree.state.downcast_ref::<HoverState>().current);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
@@ -303,9 +337,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
@@ -328,13 +366,22 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
pub fn animated<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
regions: Regions,
radius: f32,
) -> Element<'a, M> {
animated_from_edge(content, progress, regions, radius, ferese_panel::Edge::Top)
}
@@ -351,7 +398,11 @@ pub fn animated_from_edge<'a, M: 'a>(
regions: Some(regions),
radius,
scale: 1.,
direction: if edge == ferese_panel::Edge::Top { -1. } else { 1. },
direction: if edge == ferese_panel::Edge::Top {
-1.
} else {
1.
},
})
}
@@ -383,7 +434,8 @@ impl<M> Motion<'_, M> {
fn cursor(&self, layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Cursor {
cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
mouse::Cursor::Available(
layout.bounds().center() + (p - layout.bounds().center() - self.translation()) / self.scale,
layout.bounds().center()
+ (p - layout.bounds().center() - self.translation()) / self.scale,
)
})
}
@@ -396,10 +448,10 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
let mut nodes = self
.content
.as_widget()
.a11y_nodes(layout, tree, self.cursor(layout, cursor));
let mut nodes =
self.content
.as_widget()
.a11y_nodes(layout, tree, self.cursor(layout, cursor));
let offset = self.translation();
for node in nodes.root_mut() {
transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
@@ -453,9 +505,12 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
if let Some(regions) = &self.regions {
let mut collected = regions.lock().unwrap();
collected.clear();
self.content
.as_widget_mut()
.operate(tree, layout, renderer, &mut ReuseRegions(&mut collected));
self.content.as_widget_mut().operate(
tree,
layout,
renderer,
&mut ReuseRegions(&mut collected),
);
if collected.is_empty()
&& let Some(content) = layout.children().next()
{
@@ -471,9 +526,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
);
}
let cursor = self.cursor(layout, cursor);
self.content
.as_widget_mut()
.update(tree, event, layout, cursor, renderer, clipboard, shell, viewport);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn draw(
@@ -517,9 +572,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(tree, layout, self.cursor(layout, cursor), viewport, renderer)
self.content.as_widget().mouse_interaction(
tree,
layout,
self.cursor(layout, cursor),
viewport,
renderer,
)
}
fn operate(
@@ -529,7 +588,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content.as_widget_mut().operate(tree, layout, renderer, operation);
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
fn overlay<'a>(
@@ -564,7 +625,13 @@ fn transform_accessible_node(
}
}
fn transform_regions(regions: &mut [[f32; 5]], center: cosmic::iced::Point, scale: f32, offset: Vector, radius: f32) {
fn transform_regions(
regions: &mut [[f32; 5]],
center: cosmic::iced::Point,
scale: f32,
offset: Vector,
radius: f32,
) {
for region in regions {
region[0] = center.x + (region[0] - center.x) * scale + offset.x;
region[1] = center.y + (region[1] - center.y) * scale + offset.y;
@@ -580,7 +647,10 @@ impl widget::Operation for ReuseRegions<'_> {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id != Some(&widget::Id::new("ferese-blur-card")) || bounds.width <= 0. || bounds.height <= 0. {
if id != Some(&widget::Id::new("ferese-blur-card"))
|| bounds.width <= 0.
|| bounds.height <= 0.
{
return;
}
let r = [bounds.x, bounds.y, bounds.width, bounds.height, 0.];
@@ -639,21 +709,30 @@ mod tests {
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let plain_a11y =
plain
.as_widget()
.a11y_nodes(Layout::new(&plain_node), &plain_tree, mouse::Cursor::Unavailable);
let plain_a11y = plain.as_widget().a11y_nodes(
Layout::new(&plain_node),
&plain_tree,
mouse::Cursor::Unavailable,
);
assert!(!plain_a11y.root().is_empty());
let mut wrapped = animated_from_edge(make_content().into(), 0.5, Default::default(), 10., edge);
let mut wrapped =
animated_from_edge(make_content().into(), 0.5, Default::default(), 10., edge);
let mut tree = widget::Tree::new(&wrapped);
let node =
wrapped
.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let offset = if edge == ferese_panel::Edge::Top { -2. } else { 2. };
let accessible = wrapped
.as_widget()
.a11y_nodes(Layout::new(&node), &tree, mouse::Cursor::Unavailable);
let node = wrapped.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let offset = if edge == ferese_panel::Edge::Top {
-2.
} else {
2.
};
let accessible = wrapped.as_widget().a11y_nodes(
Layout::new(&node),
&tree,
mouse::Cursor::Unavailable,
);
assert_eq!(accessible.root().len(), plain_a11y.root().len());
for (actual, expected) in accessible
.root()
@@ -713,7 +792,9 @@ mod tests {
Vector::new(40., 80.),
)
.expect("motion forwards tooltip");
let plain_layout = plain_overlay.as_overlay_mut().layout(&renderer, viewport.size());
let plain_layout = plain_overlay
.as_overlay_mut()
.layout(&renderer, viewport.size());
let overlay_layout = overlay.as_overlay_mut().layout(&renderer, viewport.size());
fn tooltip_bounds(node: &layout::Node) -> Rectangle {
// Tooltip and row each return an overlay group; descend past
@@ -778,7 +859,11 @@ mod tests {
let mut regions = Vec::new();
let mut collector = ReuseRegions(&mut regions);
let card = Rectangle::new((380.0, 420.0).into(), (440.0, 220.0).into());
widget::Operation::container(&mut collector, Some(&widget::Id::new("ferese-blur-card")), card);
widget::Operation::container(
&mut collector,
Some(&widget::Id::new("ferese-blur-card")),
card,
);
transform_regions(
&mut regions,
cosmic::iced::Point::ORIGIN,
@@ -793,7 +878,10 @@ mod tests {
fn popup_waits_for_configuration_and_preserves_reversal_velocity() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_secs(30)),
0.0
);
motion.begin(now);
let reverse = now + std::time::Duration::from_millis(90);
let before = motion.sample(reverse);
@@ -804,7 +892,10 @@ mod tests {
assert_eq!(after.current, before.current);
assert_eq!(after.velocity, before.velocity);
assert!(motion.closing());
assert_eq!(motion.progress_at(reverse + std::time::Duration::from_secs(2)), 0.0);
assert_eq!(
motion.progress_at(reverse + std::time::Duration::from_secs(2)),
0.0
);
}
#[test]
@@ -850,7 +941,10 @@ mod tests {
let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen);
assert_eq!(motion.progress_at(reopen), before);
assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(2)), 1.0);
assert_eq!(
motion.progress_at(reopen + std::time::Duration::from_secs(2)),
1.0
);
for settings in [
Settings {
reduced_motion: true,
+73 -23
View File
@@ -17,7 +17,15 @@ pub(crate) fn geometry_driven<'a, M: 'a>(
completion: impl Fn(Instant) -> Option<M> + 'a,
measurement: Option<ContentMeasurement<'a, M>>,
) -> Element<'a, M> {
driven(revision, build, active, after_frame, completion, true, measurement)
driven(
revision,
build,
active,
after_frame,
completion,
true,
measurement,
)
}
pub(crate) fn frame_driven<'a, M: 'a>(
@@ -27,7 +35,15 @@ pub(crate) fn frame_driven<'a, M: 'a>(
after_frame: impl Fn(Instant) + 'a,
completion: impl Fn(Instant) -> Option<M> + 'a,
) -> Element<'a, M> {
driven(revision, build, active, after_frame, completion, false, None)
driven(
revision,
build,
active,
after_frame,
completion,
false,
None,
)
}
fn driven<'a, M: 'a>(
@@ -111,11 +127,16 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
}
fn children(&self) -> Vec<widget::Tree> {
std::iter::once(widget::Tree::new(&self.content))
.chain(self.measurement.iter().map(|m| widget::Tree::new(&m.content)))
.chain(
self.measurement
.iter()
.map(|m| widget::Tree::new(&m.content)),
)
.collect()
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.children.truncate(1 + usize::from(self.measurement.is_some()));
tree.children
.truncate(1 + usize::from(self.measurement.is_some()));
tree.children[0].diff(&mut self.content);
if let Some(measurement) = &mut self.measurement {
if tree.children.len() == 1 {
@@ -138,11 +159,13 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
if !self.measured {
if let Some(measurement) = &mut self.measurement {
// Measure at the final width once, so spring overshoot never changes wrapping or its target.
let probe_limits = layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY));
let node = measurement
.content
.as_widget_mut()
.layout(&mut tree.children[1], renderer, &probe_limits);
let probe_limits =
layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY));
let node = measurement.content.as_widget_mut().layout(
&mut tree.children[1],
renderer,
&probe_limits,
);
(measurement.measured)(node.size());
let now = Instant::now();
self.content = (self.build)(now);
@@ -161,7 +184,9 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn update(
&mut self,
@@ -174,7 +199,8 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let redraw = if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
let redraw = if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event
{
tree.state
.downcast_mut::<FrameState>()
.begin(*at, self.revision)
@@ -234,7 +260,10 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
if std::mem::take(&mut tree.state.downcast_mut::<FrameState>().pending) {
(self.after_frame)(*at);
let completion = (self.completion)(*at);
if tree.state.downcast_mut::<FrameState>().complete(completion.is_none())
if tree
.state
.downcast_mut::<FrameState>()
.complete(completion.is_none())
&& let Some(message) = completion
{
shell.publish(message);
@@ -252,9 +281,15 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
@@ -264,9 +299,13 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
@@ -287,9 +326,13 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'b, M, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -386,11 +429,18 @@ mod tests {
);
assert_eq!(shell.redraw_request(), RedrawRequest::Wait);
assert_eq!(messages.len(), 1);
assert_eq!(samples.borrow().len(), settled_builds, "settled redraw rebuilt content");
assert_eq!(
samples.borrow().len(),
settled_builds,
"settled redraw rebuilt content"
);
let values = samples.borrow();
assert!(values.windows(2).all(|pair| pair[1] >= pair[0]));
assert_eq!(values.last(), Some(&1.0));
assert!(values.len() > (hz / 4) as usize, "high refresh callbacks were skipped");
assert!(
values.len() > (hz / 4) as usize,
"high refresh callbacks were skipped"
);
}
// Keep this mutable: the real render path shares the same renderer.
renderer.reset(Rectangle::default());
+128 -55
View File
@@ -8,7 +8,11 @@ const POPUP_WIDTH: u32 = 368;
const HISTORY_MAX_HEIGHT: f32 = 620.0;
fn popup_height(notice: &notifications::Notice, count: usize) -> u32 {
let body = if notice.body.is_empty() && count == 1 { 0 } else { 22 };
let body = if notice.body.is_empty() && count == 1 {
0
} else {
22
};
let actions = if notice.live && notice.actions.iter().any(|(key, _)| key != "default") {
39
} else {
@@ -111,7 +115,10 @@ fn icon_control<'a>(
);
cosmic::widget::tooltip(
control,
render.text(label).size(11).class(theme::Text::Color(foreground)),
render
.text(label)
.size(11)
.class(theme::Text::Color(foreground)),
cosmic::widget::tooltip::Position::Left,
)
.class(theme::Container::custom(move |_| container::Style {
@@ -155,7 +162,8 @@ impl FereseShell {
.map(|menu| self.panel_for_bar(menu.anchor.parent))
.unwrap_or(&self.config.panels[0])
.resolved_geometry();
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0).max(1.0)
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0)
.max(1.0)
})
.min(HISTORY_MAX_HEIGHT)
}
@@ -224,10 +232,9 @@ impl FereseShell {
.outputs
.iter()
.find(|entry| {
self.snapshot
.outputs
.iter()
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
self.snapshot.outputs.iter().any(|output| {
output.focused && Some(output.name.as_str()) == entry.name.as_deref()
})
})
.or_else(|| self.outputs.first())
.map(|entry| entry.output.clone());
@@ -250,7 +257,9 @@ impl FereseShell {
.notifications
.popup_groups()
.iter()
.map(|(notice, _, count)| popup_height(notice, *count) + if *count > 1 { 12 } else { 0 })
.map(|(notice, _, count)| {
popup_height(notice, *count) + if *count > 1 { 12 } else { 0 }
})
.sum::<u32>()
+ count.saturating_sub(1) as u32 * 8
+ 8;
@@ -264,7 +273,12 @@ impl FereseShell {
.outputs
.iter()
.find(|entry| entry.output == output)
.map(|entry| entry.runtime.panel(&self.config.panels[0]).resolved_geometry())
.map(|entry| {
entry
.runtime
.panel(&self.config.panels[0])
.resolved_geometry()
})
.unwrap_or_else(|| self.config.panels[0].resolved_geometry());
let top = geometry.height + geometry.edge_margin as f32 + 12.0;
let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32);
@@ -338,7 +352,8 @@ impl FereseShell {
let id = notice.id;
let mut bold = self.render.font;
bold.weight = cosmic::iced::font::Weight::Bold;
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() {
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty()
{
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
} else if notice.icon.starts_with('/') {
icon::icon(icon::from_path(notice.icon.clone().into())).size(18)
@@ -356,7 +371,8 @@ impl FereseShell {
palette.surface_popover
}),
);
let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let (badge_fill, badge_text) =
ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let badge = container(
self.render
.text(count.to_string())
@@ -457,13 +473,18 @@ impl FereseShell {
};
if !body.is_empty() {
content = content.push(
container(self.render.text(body).size(13).class(theme::Text::Color(muted)))
.height(if history && count == 1 {
Length::Shrink
} else {
Length::Fixed(18.0)
})
.clip(true),
container(
self.render
.text(body)
.size(13)
.class(theme::Text::Color(muted)),
)
.height(if history && count == 1 {
Length::Shrink
} else {
Length::Fixed(18.0)
})
.clip(true),
);
}
let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill));
@@ -478,13 +499,13 @@ impl FereseShell {
let mut actions = row([]);
for (index, (key, label)) in available.into_iter().enumerate() {
if index > 0 {
actions =
actions.push(container(cosmic::iced::widget::Space::new().width(1).height(38)).class(
theme::Container::custom(move |_| container::Style {
actions = actions.push(
container(cosmic::iced::widget::Space::new().width(1).height(38))
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(divider)),
..Default::default()
}),
));
})),
);
}
actions = actions.push(card_button(
self.render,
@@ -505,12 +526,17 @@ impl FereseShell {
}
face = face
.push(
container(cosmic::iced::widget::Space::new().width(Length::Fill).height(1)).class(
theme::Container::custom(move |_| container::Style {
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(1),
)
.class(theme::Container::custom(move |_| {
container::Style {
background: Some(Background::Color(divider)),
..Default::default()
}),
),
}
})),
)
.push(actions);
}
@@ -544,20 +570,25 @@ impl FereseShell {
// bottom edges, after the card's measured height.
let back = move |inset: f32| {
container(
container(cosmic::iced::widget::Space::new().width(Length::Fill).height(6))
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
a: 0.045 * opacity,
..foreground
})),
border: Border {
shape: BorderShape::Continuous,
radius: [0.0, 0.0, self.render.radius(6.0), self.render.radius(6.0)].into(),
..Default::default()
},
..Default::default()
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(6),
)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
a: 0.045 * opacity,
..foreground
})),
border: Border {
shape: BorderShape::Continuous,
radius: [0.0, 0.0, self.render.radius(6.0), self.render.radius(6.0)]
.into(),
..Default::default()
},
..Default::default()
})),
)
.padding([0.0, inset])
};
@@ -575,7 +606,10 @@ impl FereseShell {
Message::ToggleNotificationHistory
}));
} else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") {
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(id, "default".into())));
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(
id,
"default".into(),
)));
}
area.into()
}
@@ -608,7 +642,10 @@ impl FereseShell {
)
}
pub(super) fn view_notifications_at(&self, now: std::time::Instant) -> Element<'_, cosmic::Action<Message>> {
pub(super) fn view_notifications_at(
&self,
now: std::time::Instant,
) -> Element<'_, cosmic::Action<Message>> {
if self.notifications.history_open {
let palette = self.config.theme;
let surface = self.menu.as_ref();
@@ -630,7 +667,11 @@ impl FereseShell {
} else {
format!(
"{count} {} · {} {}",
if count == 1 { "notification" } else { "notifications" },
if count == 1 {
"notification"
} else {
"notifications"
},
groups.len(),
if groups.len() == 1 { "app" } else { "apps" }
)
@@ -644,7 +685,12 @@ impl FereseShell {
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(self.render.text(subtitle).size(11).class(theme::Text::Color(muted)));
.push(
self.render
.text(subtitle)
.size(11)
.class(theme::Text::Color(muted)),
);
let header = row([])
.spacing(10)
.align_y(alignment::Vertical::Center)
@@ -658,7 +704,11 @@ impl FereseShell {
well,
28,
));
let dnd_color = if self.notifications.dnd { accent } else { muted };
let dnd_color = if self.notifications.dnd {
accent
} else {
muted
};
let dnd_icon = if self.notifications.dnd {
ferese_theme::icons::NOTIFICATIONS_OFF
} else {
@@ -713,7 +763,8 @@ impl FereseShell {
self.notifications.dnd,
1.0,
));
let controls = ferese_theme::menus::section(dnd.into(), primary, opacity, palette.material_radius);
let controls =
ferese_theme::menus::section(dnd.into(), primary, opacity, palette.material_radius);
let mut content = column([]).spacing(10).push(header).push(controls);
if count == 0 {
let empty = column([])
@@ -738,7 +789,11 @@ impl FereseShell {
.size(11)
.class(theme::Text::Color(muted)),
);
content = content.push(container(empty).center_x(Length::Fill).center_y(Length::Fill));
content = content.push(
container(empty)
.center_x(Length::Fill)
.center_y(Length::Fill),
);
} else {
content = content.push(
row([])
@@ -805,7 +860,12 @@ impl FereseShell {
}
entries = entries.push(cards);
} else {
entries = entries.push(self.notification_card(latest, opacity, true, group.len()));
entries = entries.push(self.notification_card(
latest,
opacity,
true,
group.len(),
));
}
}
content = content.push(
@@ -825,7 +885,8 @@ impl FereseShell {
.max_height(self.notification_history_height_limit())
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: (!compositor_material).then_some(Background::Color(color(palette.surface_popover))),
background: (!compositor_material)
.then_some(Background::Color(color(palette.surface_popover))),
border: Border {
shape: BorderShape::Continuous,
color: color_with_opacity(palette.text_muted, 0.18 * opacity),
@@ -840,7 +901,9 @@ impl FereseShell {
motion::animated_from_edge(
panel,
progress,
surface.map(|surface| surface.regions.clone()).unwrap_or_default(),
surface
.map(|surface| surface.regions.clone())
.unwrap_or_default(),
palette.material_radius,
self.config.panels[0].edge,
)
@@ -920,7 +983,12 @@ mod tests {
mouse::Cursor::Unavailable,
&bounds,
);
let pixels = Headless::screenshot(&mut renderer, Size::new(120, 60), 1.0, Color::TRANSPARENT);
let pixels = Headless::screenshot(
&mut renderer,
Size::new(120, 60),
1.0,
Color::TRANSPARENT,
);
let center = node.bounds().center();
let alpha = pixels[((center.y as usize * 120 + center.x as usize) * 4) + 3];
assert!(
@@ -983,9 +1051,11 @@ mod tests {
},
);
let mut tree = Tree::new(card.as_widget());
let node =
card.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
let node = card.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
renderer.reset(bounds);
card.as_widget().draw(
&tree,
@@ -996,7 +1066,10 @@ mod tests {
mouse::Cursor::Available((80.0, 100.0).into()),
&bounds,
);
let size = Size::new((bounds.width * scale) as u32, (bounds.height * scale) as u32);
let size = Size::new(
(bounds.width * scale) as u32,
(bounds.height * scale) as u32,
);
let pixels = Headless::screenshot(&mut renderer, size, scale, Color::TRANSPARENT);
let alpha = |x: u32, y: u32| pixels[((y * size.width + x) * 4 + 3) as usize];
assert_eq!(
+104 -25
View File
@@ -94,7 +94,9 @@ impl Server {
let mut registry = self.registry.lock().unwrap();
let replacing = registry.live.contains_key(&replaces_id);
if !replacing && registry.live.len() >= HISTORY_LIMIT {
return Err(zbus::fdo::Error::LimitsExceeded("Too many active notifications".into()));
return Err(zbus::fdo::Error::LimitsExceeded(
"Too many active notifications".into(),
));
}
let id = if replacing {
replaces_id
@@ -196,7 +198,10 @@ impl Service {
Self::connect(config, None)
}
fn connect(config: &ferese_config::notifications::NotificationConfig, address: Option<String>) -> Self {
fn connect(
config: &ferese_config::notifications::NotificationConfig,
address: Option<String>,
) -> Self {
let timeout = Arc::new(std::sync::atomic::AtomicU32::new(config.timeout_ms));
let worker_timeout = timeout.clone();
let (sender, events) = tokio::sync::mpsc::channel(256);
@@ -231,20 +236,41 @@ impl Service {
registry: registry.clone(),
timeout: worker_timeout.clone(),
};
let _ = server.notify("Ferese", 0, "", &title, &body, vec![], HashMap::new(), -1);
let _ = server.notify(
"Ferese",
0,
"",
&title,
&body,
vec![],
HashMap::new(),
-1,
);
continue;
}
Command::Close(id, revision, reason) => {
if !registry.lock().unwrap().close(id, revision, reason) {
continue;
}
connection.emit_signal(None::<&str>, PATH, INTERFACE, "NotificationClosed", &(id, reason))
connection.emit_signal(
None::<&str>,
PATH,
INTERFACE,
"NotificationClosed",
&(id, reason),
)
}
Command::Action(id, revision, action) => {
if registry.lock().unwrap().live.get(&id) != Some(&revision) {
continue;
}
connection.emit_signal(None::<&str>, PATH, INTERFACE, "ActionInvoked", &(id, action))
connection.emit_signal(
None::<&str>,
PATH,
INTERFACE,
"ActionInvoked",
&(id, action),
)
}
};
if let Err(error) = result {
@@ -284,7 +310,10 @@ pub struct Center {
}
impl Center {
pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self {
pub fn new(
config: ferese_config::notifications::NotificationConfig,
motion: super::motion::Settings,
) -> Self {
Self {
activity_bridge: false,
service: Service::start(&config),
@@ -354,7 +383,9 @@ impl Center {
}
pub fn frame_active(&self, now: Instant) -> bool {
self.toasts.iter().any(|toast| toast.motion.frame_active(now))
self.toasts
.iter()
.any(|toast| toast.motion.frame_active(now))
}
pub fn has_finished_closes(&self, now: Instant) -> bool {
@@ -364,7 +395,10 @@ impl Center {
}
pub fn motion_revision(&self) -> Option<Instant> {
self.toasts.iter().filter_map(|toast| toast.motion.revision()).max()
self.toasts
.iter()
.filter_map(|toast| toast.motion.revision())
.max()
}
pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> {
@@ -374,7 +408,10 @@ impl Center {
pub fn popup_groups_at(&self, now: Instant) -> Vec<(&Notice, f32, usize)> {
let mut groups: Vec<(&Notice, f32, usize)> = Vec::new();
for (notice, opacity) in self.visible_at(now) {
if let Some(group) = groups.iter_mut().find(|(head, _, _)| head.app == notice.app) {
if let Some(group) = groups
.iter_mut()
.find(|(head, _, _)| head.app == notice.app)
{
group.2 += 1;
} else {
groups.push((notice, opacity, 1));
@@ -473,7 +510,10 @@ impl Center {
}
pub fn invoke(&mut self, id: u32, action: String) {
if let Some(notice) = self.entries.iter().find(|notice| notice.id == id && notice.live)
if let Some(notice) = self
.entries
.iter()
.find(|notice| notice.id == id && notice.live)
&& notice.actions.iter().any(|(key, _)| key == &action)
{
let resident = notice.resident;
@@ -524,7 +564,8 @@ impl Center {
}
}
notice.unread = !self.history_open;
let show = !self.activity_bridge && self.config.show_popups && (!self.dnd || notice.critical);
let show =
!self.activity_bridge && self.config.show_popups && (!self.dnd || notice.critical);
if show {
self.toasts.push_back(Toast {
id: notice.id,
@@ -574,7 +615,11 @@ impl Center {
fn hidden_deadlines(&self) -> impl Iterator<Item = (u32, Instant)> + '_ {
self.entries.iter().filter_map(|notice| {
(notice.live && !self.toasts.iter().any(|toast| toast.id == notice.id))
.then(|| notice.timeout.map(|timeout| (notice.id, notice.received_at + timeout)))
.then(|| {
notice
.timeout
.map(|timeout| (notice.id, notice.received_at + timeout))
})
.flatten()
})
}
@@ -589,7 +634,9 @@ impl Center {
}
pub fn tick_subscription(&self) -> cosmic::iced::Subscription<()> {
fn deadline_stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
fn deadline_stream(
deadline: &Instant,
) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
let deadline = *deadline;
cosmic::iced::futures::stream::once(async move {
tokio::time::sleep(deadline.saturating_duration_since(Instant::now())).await;
@@ -646,8 +693,9 @@ impl Center {
for id in expired {
self.close(id, 1);
}
self.toasts
.retain(|toast| toast.closing.is_none() || (!self.history_open && toast.motion.animating()));
self.toasts.retain(|toast| {
toast.closing.is_none() || (!self.history_open && toast.motion.animating())
});
let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect();
self.entries
.retain(|notice| !notice.transient || notice.live || visible.contains(&notice.id));
@@ -724,7 +772,10 @@ mod tests {
assert!(app.activity_available(bar));
drop(app.update(crate::Message::Control(crate::status::Action::Dnd(true))));
assert!(!app.activity_available(bar));
assert!(app.notifications.entries[0].live, "DND must not dismiss the notice");
assert!(
app.notifications.entries[0].live,
"DND must not dismiss the notice"
);
drop(app.update(crate::Message::Control(crate::status::Action::Dnd(false))));
assert!(app.activity_available(bar));
}
@@ -747,7 +798,9 @@ mod tests {
let mut multilingual = notice(1);
multilingual.title = "通知🛠️".repeat(100);
multilingual.app = "アプリ".repeat(100);
drop(app.update(crate::Message::NotificationEvent(Event::Notice(multilingual))));
drop(app.update(crate::Message::NotificationEvent(Event::Notice(
multilingual,
))));
assert!(app.activity_available(bar));
assert!(app.notifications.toasts.is_empty());
app.notifications.entries[0].received_at = Instant::now() - Duration::from_secs(7);
@@ -784,7 +837,12 @@ mod tests {
fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() {
let (mut center, _) = fixture();
center.receive(notice(1));
center.toasts.front_mut().unwrap().motion.begin(Instant::now());
center
.toasts
.front_mut()
.unwrap()
.motion
.begin(Instant::now());
center.history_open = true;
center.close(1, 2);
center.tick();
@@ -916,7 +974,10 @@ mod tests {
center.last_tick = Instant::now() - Duration::from_secs(10);
center.tick();
assert!(!center.entries[0].live);
assert!(matches!(commands.try_recv().unwrap(), Command::Close(1, 1, 1)));
assert!(matches!(
commands.try_recv().unwrap(),
Command::Close(1, 1, 1)
));
}
#[test]
@@ -963,7 +1024,10 @@ mod tests {
center.invoke(1, "invalid".into());
assert!(commands.try_recv().is_err());
center.invoke(1, "open".into());
assert!(matches!(commands.try_recv().unwrap(), Command::Action(1, 1, _)));
assert!(matches!(
commands.try_recv().unwrap(),
Command::Action(1, 1, _)
));
assert!(center.entries[0].live);
}
@@ -985,7 +1049,13 @@ mod tests {
..Default::default()
});
assert!(center.dnd);
assert_eq!(center.service.timeout.load(std::sync::atomic::Ordering::Relaxed), 10000);
assert_eq!(
center
.service
.timeout
.load(std::sync::atomic::Ordering::Relaxed),
10000
);
center.receive(notice(1));
center.configure(NotificationConfig {
show_popups: false,
@@ -1038,7 +1108,8 @@ mod tests {
.build()
.unwrap();
let proxy = zbus::blocking::Proxy::new(&connection, INTERFACE, PATH, INTERFACE).unwrap();
let info: (String, String, String, String) = proxy.call("GetServerInformation", &()).unwrap();
let info: (String, String, String, String) =
proxy.call("GetServerInformation", &()).unwrap();
assert_eq!(info.0, "Ferese");
let notify = |replace| -> u32 {
proxy
@@ -1072,14 +1143,22 @@ mod tests {
let (sender, receiver) = mpsc::channel();
let worker = std::thread::spawn(move || {
let signal = signals.next().unwrap();
sender.send(signal.body().deserialize::<(u32, u32)>().unwrap()).unwrap();
sender
.send(signal.body().deserialize::<(u32, u32)>().unwrap())
.unwrap();
});
proxy.call::<_, _, ()>("CloseNotification", &(id,)).unwrap();
let Event::Close(closed, revision) = received(&service) else {
panic!("Expected close");
};
service.commands.send(Command::Close(closed, revision, 3)).unwrap();
assert_eq!(receiver.recv_timeout(Duration::from_secs(3)).unwrap(), (id, 3));
service
.commands
.send(Command::Close(closed, revision, 3))
.unwrap();
assert_eq!(
receiver.recv_timeout(Duration::from_secs(3)).unwrap(),
(id, 3)
);
worker.join().unwrap();
assert!(proxy.call::<_, _, ()>("CloseNotification", &(id,)).is_err());
}
File diff suppressed because it is too large. Load diff
+37 -8
View File
@@ -17,7 +17,10 @@ impl FereseShell {
.as_ref()
.filter(|battery| {
battery.percent < self.config.status.low_battery_threshold
&& matches!(battery.status.as_str(), "Discharging" | "Pending discharge" | "Empty")
&& matches!(
battery.status.as_str(),
"Discharging" | "Pending discharge" | "Empty"
)
})
.map(|battery| battery.percent)
}
@@ -53,7 +56,15 @@ impl FereseShell {
.and_then(|runtime| runtime.borrow().idle_content_width)
.unwrap_or(self.panel_for_bar(id).morph.idle_width);
let workspaces = self.hub_workspace_width(id);
return (Peek::Date, width + if workspaces > 0. { workspaces + 10. } else { 0. });
return (
Peek::Date,
width
+ if workspaces > 0. {
workspaces + 10.
} else {
0.
},
);
}
let player = self.media.snapshot.selected.as_ref().filter(|player| {
matches!(
@@ -122,7 +133,11 @@ impl FereseShell {
.size(13)
.class(theme::Text::Color(foreground))
.width(width)
.align_x(if expanded { Alignment::End } else { Alignment::Start })
.align_x(if expanded {
Alignment::End
} else {
Alignment::Start
})
]
.spacing(8)
.align_y(Alignment::Center)
@@ -136,7 +151,11 @@ impl FereseShell {
.align_y(Alignment::Center)
.width(width)
.push(icons::tinted(
if saving { icons::RECORD_SAVING } else { icons::RECORD },
if saving {
icons::RECORD_SAVING
} else {
icons::RECORD
},
18,
warning,
));
@@ -153,8 +172,13 @@ impl FereseShell {
// Reserve the same width for every second, regardless of digit advances.
let timer_width = elapsed.len().max(5) as f32 * 8.;
strip = strip.push(
container(self.render.text(elapsed).size(13).class(theme::Text::Color(foreground)))
.center_x(timer_width),
container(
self.render
.text(elapsed)
.size(13)
.class(theme::Text::Color(foreground)),
)
.center_x(timer_width),
);
} else {
strip = strip.push(
@@ -181,7 +205,9 @@ impl FereseShell {
self.battery_indicator(self.config.theme, opacity, true),
28.,
))
.name(format!("Low battery, {percent} percent. Open battery controls"))
.name(format!(
"Low battery, {percent} percent. Open battery controls"
))
.padding(0)
.height(28)
.class(theme::Button::Custom {
@@ -190,7 +216,10 @@ impl FereseShell {
pressed: Box::new(move |_, _| style),
disabled: Box::new(move |_| style),
})
.on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Battery))),
.on_press(cosmic::Action::App(Message::HubOpen(
id,
status_ui::Menu::Battery,
))),
);
}
strip.into()
+4 -1
View File
@@ -26,7 +26,10 @@ impl FereseShell {
.padding([0, 3])
.width(WIDTH as f32)
.height(28)
.on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Battery))),
.on_press(cosmic::Action::App(Message::HubOpen(
id,
status_ui::Menu::Battery,
))),
foreground,
active,
opacity,
@@ -1,5 +1,7 @@
use crate::Message;
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::advanced::{
Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget,
};
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
use cosmic::{Element, Theme};
@@ -8,7 +10,11 @@ type Action = cosmic::Action<Message>;
// Content keeps its destination layout while the shell springs around it.
// Centering against the animated width cancels the shell's movement; anchoring
// to the screen edge keeps its vertical position stable too.
pub(super) fn view(content: Element<'_, Action>, size: [f64; 2], edge: ferese_panel::Edge) -> Element<'_, Action> {
pub(super) fn view(
content: Element<'_, Action>,
size: [f64; 2],
edge: ferese_panel::Edge,
) -> Element<'_, Action> {
Element::new(ContentFrame {
content,
target: Size::new(size[0] as f32, size[1] as f32),
+24 -5
View File
@@ -1,12 +1,18 @@
use std::time::Instant;
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::advanced::{
Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget,
};
use cosmic::iced::{Color, Event, Length, Rectangle, Size, window};
use cosmic::{Element, Theme};
use crate::Message;
pub(super) fn view<'a>(started: Instant, animate: bool, color: Color) -> Element<'a, cosmic::Action<Message>> {
pub(super) fn view<'a>(
started: Instant,
animate: bool,
color: Color,
) -> Element<'a, cosmic::Action<Message>> {
Element::new(Equalizer {
started,
animate,
@@ -33,7 +39,12 @@ impl Widget<cosmic::Action<Message>, Theme, cosmic::Renderer> for Equalizer {
Size::new(Length::Fixed(13.), Length::Fixed(14.))
}
fn layout(&mut self, _: &mut widget::Tree, _: &cosmic::Renderer, limits: &layout::Limits) -> layout::Node {
fn layout(
&mut self,
_: &mut widget::Tree,
_: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::Node::new(limits.resolve(13., 14., Size::ZERO))
}
@@ -78,12 +89,20 @@ impl Widget<cosmic::Action<Message>, Theme, cosmic::Renderer> for Equalizer {
renderer.with_layer(clip, |renderer| {
for index in 0..3 {
let phase = (elapsed - index as f32 * 0.2) * std::f32::consts::TAU / 0.9;
let fraction = if self.animate { 0.65 + 0.35 * phase.sin() } else { 0.45 };
let fraction = if self.animate {
0.65 + 0.35 * phase.sin()
} else {
0.45
};
let height = bounds.height * fraction;
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
(bounds.x + index as f32 * 5., bounds.y + bounds.height - height).into(),
(
bounds.x + index as f32 * 5.,
bounds.y + bounds.height - height,
)
.into(),
(3., height).into(),
),
border: cosmic::iced::Border {
+29 -11
View File
@@ -62,7 +62,9 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
&& cursor.is_over(*viewport)
&& let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event
{
shell.publish(cosmic::Action::App(Message::HubWorkspaceScroll(self.id, *delta)));
shell.publish(cosmic::Action::App(Message::HubWorkspaceScroll(
self.id, *delta,
)));
shell.capture_event();
}
}
@@ -76,9 +78,15 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn a11y_nodes(
&self,
@@ -86,7 +94,9 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn mouse_interaction(
&self,
@@ -96,9 +106,13 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
@@ -119,8 +133,12 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Action, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -115,7 +115,8 @@ impl Runtime {
}
Event::Activity { priority, peek } => {
let changed = self.priority != priority;
let resized = priority.is_some() && current == Mode::Peek && self.peek_width != peek.1;
let resized =
priority.is_some() && current == Mode::Peek && self.peek_width != peek.1;
self.priority = priority;
if (!changed && !resized) || current.expanded() {
return false;
@@ -140,7 +141,8 @@ impl Runtime {
(Mode::Toast, None)
}
Event::ToastExpired(token) => {
if !matches!(self.transient, Transient::Toast { token: live, .. } if live == token) {
if !matches!(self.transient, Transient::Toast { token: live, .. } if live == token)
{
return false;
}
self.restore_transient();
@@ -55,11 +55,19 @@ struct FocusedWorkspace {
}
impl cosmic::iced::advanced::widget::Operation<Option<u64>> for FocusedWorkspace {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<u64>>)) {
fn traverse(
&mut self,
operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<u64>>),
) {
operate(self);
}
fn focusable(&mut self, id: Option<&Id>, _: cosmic::iced::Rectangle, state: &mut dyn operation::Focusable) {
fn focusable(
&mut self,
id: Option<&Id>,
_: cosmic::iced::Rectangle,
state: &mut dyn operation::Focusable,
) {
if state.is_focused() {
self.focused = self
.tiles
@@ -77,12 +85,21 @@ impl cosmic::iced::advanced::widget::Operation<Option<u64>> for FocusedWorkspace
// Fit the actual rectangles together, preserving aspect and relative positions.
fn mini_rects(windows: &[&WindowSnapshot]) -> Vec<(u64, cosmic::iced::Rectangle)> {
let rectangles: Vec<_> = windows.iter().filter_map(|w| w.geometry.map(|r| (w.id, r))).collect();
let rectangles: Vec<_> = windows
.iter()
.filter_map(|w| w.geometry.map(|r| (w.id, r)))
.collect();
if rectangles.is_empty() {
return Vec::new();
}
let x = rectangles.iter().map(|(_, r)| r[0]).fold(f64::INFINITY, f64::min);
let y = rectangles.iter().map(|(_, r)| r[1]).fold(f64::INFINITY, f64::min);
let x = rectangles
.iter()
.map(|(_, r)| r[0])
.fold(f64::INFINITY, f64::min);
let y = rectangles
.iter()
.map(|(_, r)| r[1])
.fold(f64::INFINITY, f64::min);
let right = rectangles
.iter()
.map(|(_, r)| r[0] + r[2])
@@ -111,7 +128,12 @@ fn mini_rects(windows: &[&WindowSnapshot]) -> Vec<(u64, cosmic::iced::Rectangle)
}
impl FereseShell {
pub(crate) fn hub_workspace_move(&mut self, id: window::Id, window: u64, destination: u64) -> Task<Message> {
pub(crate) fn hub_workspace_move(
&mut self,
id: window::Id,
window: u64,
destination: u64,
) -> Task<Message> {
let output = self.output_for_bar(id).map(|o| o.id);
let source = self
.snapshot
@@ -119,7 +141,11 @@ impl FereseShell {
.iter()
.find(|w| w.id == window)
.map(|w| w.workspace);
let valid = |workspace| self.snapshot.workspaces_for_output(output).any(|w| w.id == workspace);
let valid = |workspace| {
self.snapshot
.workspaces_for_output(output)
.any(|w| w.id == workspace)
};
if source.is_some_and(valid)
&& valid(destination)
&& let Some(control) = &self.control
@@ -129,7 +155,11 @@ impl FereseShell {
Task::none()
}
pub(crate) fn hub_workspace_hover(&mut self, id: window::Id, workspace: Option<u64>) -> Task<Message> {
pub(crate) fn hub_workspace_hover(
&mut self,
id: window::Id,
workspace: Option<u64>,
) -> Task<Message> {
if let Some(runtime) = self
.outputs
.iter()
@@ -169,17 +199,26 @@ impl FereseShell {
self.query_hub_workspace_focus(id, Some(key))
}
pub(crate) fn query_hub_workspace_focus(&self, id: window::Id, key: Option<Named>) -> Task<Message> {
pub(crate) fn query_hub_workspace_focus(
&self,
id: window::Id,
key: Option<Named>,
) -> Task<Message> {
let output = self.output_for_bar(id);
let tiles = tiles(
self.snapshot.workspaces_for_output(output.map(|o| o.id)).collect(),
self.snapshot
.workspaces_for_output(output.map(|o| o.id))
.collect(),
output.map_or(0, |o| o.active_workspace),
)
.into_iter()
.map(|workspace| (tile_id(id, workspace.id), workspace.id))
.collect();
cosmic::iced::advanced::widget::operate(FocusedWorkspace { tiles, focused: None })
.map(move |focused| cosmic::Action::App(Message::HubWorkspaceFocus(id, focused, key)))
cosmic::iced::advanced::widget::operate(FocusedWorkspace {
tiles,
focused: None,
})
.map(move |focused| cosmic::Action::App(Message::HubWorkspaceFocus(id, focused, key)))
}
pub(crate) fn hub_workspace_focus(
@@ -199,7 +238,9 @@ impl FereseShell {
return Task::none();
};
let workspaces = tiles(
self.snapshot.workspaces_for_output(Some(output.id)).collect(),
self.snapshot
.workspaces_for_output(Some(output.id))
.collect(),
output.active_workspace,
);
let Some(runtime) = self
@@ -216,7 +257,10 @@ impl FereseShell {
return Task::none();
};
let selected = focused.unwrap_or(output.active_workspace);
let current = workspaces.iter().position(|w| w.id == selected).unwrap_or(0);
let current = workspaces
.iter()
.position(|w| w.id == selected)
.unwrap_or(0);
let cols = columns((runtime.hub.target_size()[0] - 28.) as f32);
let next = match key {
Named::ArrowLeft => current.saturating_sub(1),
@@ -232,7 +276,10 @@ impl FereseShell {
runtime.workspace_hover = None;
let rows = workspaces.len().div_ceil(cols);
Task::batch([
cosmic::iced::advanced::widget::operate(operation::focusable::focus(tile_id(id, workspace.id))),
cosmic::iced::advanced::widget::operate(operation::focusable::focus(tile_id(
id,
workspace.id,
))),
cosmic::iced::widget::scrollable::snap_to(
Id::new(format!("morph-workspace-grid-{id:?}")),
cosmic::iced::widget::scrollable::RelativeOffset {
@@ -247,11 +294,17 @@ impl FereseShell {
])
}
pub(crate) fn hub_workspace_grid(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
pub(crate) fn hub_workspace_grid(
&self,
id: window::Id,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let output = self.output_for_bar(id);
let active = output.map_or(0, |o| o.active_workspace);
let workspaces = tiles(
self.snapshot.workspaces_for_output(output.map(|o| o.id)).collect(),
self.snapshot
.workspaces_for_output(output.map(|o| o.id))
.collect(),
active,
);
let runtime = self
@@ -274,7 +327,11 @@ impl FereseShell {
.find(|w| w.id == hovered)
.or_else(|| workspaces.iter().find(|w| w.id == active));
let mut label = current.map_or_else(|| "No workspaces".to_owned(), |w| caption(w));
if let Some(workspace) = current.filter(|_| runtime.as_ref().is_some_and(|r| r.workspace_hover.is_some())) {
if let Some(workspace) = current.filter(|_| {
runtime
.as_ref()
.is_some_and(|r| r.workspace_hover.is_some())
}) {
let titles = self
.snapshot
.windows
@@ -324,16 +381,26 @@ impl FereseShell {
let label = format!(
"{}{}{}",
caption(workspace),
if workspace_id == active { ", active" } else { "" },
windows.iter().map(|w| format!(", {}", w.title)).collect::<String>()
if workspace_id == active {
", active"
} else {
""
},
windows
.iter()
.map(|w| format!(", {}", w.title))
.collect::<String>()
);
let transition = self.workspace_ui.transition(workspace_id, workspace_id == active);
let transition = self
.workspace_ui
.transition(workspace_id, workspace_id == active);
line = line.push(motion::geometry_driven(
transition.revision(),
move |now| {
let progress = transition.progress(now).clamp(0., 1.);
let alpha = opacity * if empty { 0.6 } else { 1. };
let foreground = ferese_theme::mix(palette.muted, palette.accent, progress).scale_alpha(alpha);
let foreground = ferese_theme::mix(palette.muted, palette.accent, progress)
.scale_alpha(alpha);
let fill = cosmic::iced::Color {
a: (0.055 + 0.105 * progress) * alpha,
..ferese_theme::mix(palette.text, palette.accent, progress)
@@ -358,23 +425,30 @@ impl FereseShell {
let pulse = if self.render.motion.motion_enabled() {
0.65 + 0.35
* (now
.saturating_duration_since(transition.revision().unwrap_or(now))
.saturating_duration_since(
transition.revision().unwrap_or(now),
)
.as_secs_f32()
* 4.)
.cos()
} else {
1.
};
heading = heading.push(container(cosmic::widget::Space::new()).width(4).height(4).class(
theme::Container::custom(move |_| container::Style {
background: Some(palette.error.scale_alpha(alpha * pulse).into()),
border: cosmic::iced::Border {
radius: 2.0.into(),
heading = heading.push(
container(cosmic::widget::Space::new())
.width(4)
.height(4)
.class(theme::Container::custom(move |_| container::Style {
background: Some(
palette.error.scale_alpha(alpha * pulse).into(),
),
border: cosmic::iced::Border {
radius: 2.0.into(),
..Default::default()
},
..Default::default()
},
..Default::default()
}),
));
})),
);
}
let content = column![
container(heading).height(14),
@@ -395,19 +469,29 @@ impl FereseShell {
..appearance
}),
hovered: Box::new(move |_, _| button::Style {
background: Some(palette.accent.scale_alpha(0.12 * alpha).into()),
background: Some(
palette.accent.scale_alpha(0.12 * alpha).into(),
),
..appearance
}),
pressed: Box::new(move |_, _| appearance),
disabled: Box::new(move |_| appearance),
})
.on_press(cosmic::Action::App(Message::HubWorkspaceSelect(id, workspace_id))),
.on_press(cosmic::Action::App(Message::HubWorkspaceSelect(
id,
workspace_id,
))),
)
.on_enter(cosmic::Action::App(Message::HubWorkspaceHover(id, Some(workspace_id))))
.on_enter(cosmic::Action::App(Message::HubWorkspaceHover(
id,
Some(workspace_id),
)))
.on_exit(cosmic::Action::App(Message::HubWorkspaceHover(id, None)))
.into()
},
move |now| transition.active(now) || (urgent && self.render.motion.motion_enabled()),
move |now| {
transition.active(now) || (urgent && self.render.motion.motion_enabled())
},
|_| {},
|_| None,
None,
@@ -455,7 +539,9 @@ mod tests {
#[test]
fn grid_keeps_occupied_active_and_one_empty_destination_without_a_ten_tile_cap() {
let mut all: Vec<_> = (1..=14).map(|id| workspace(id, u32::from(id < 12))).collect();
let mut all: Vec<_> = (1..=14)
.map(|id| workspace(id, u32::from(id < 12)))
.collect();
all.reverse();
let shown = tiles(all.iter().collect(), 13);
assert_eq!(
@@ -527,8 +613,12 @@ mod tests {
Box::new(operation::focusable::focus_next())
};
loop {
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut tab);
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut tab,
);
match tab.finish() {
operation::Outcome::Chain(next) => tab = next,
_ => break,
@@ -11,8 +11,16 @@ pub(super) struct Target {
pub windows: Vec<(u64, Rectangle)>,
}
pub(super) fn area(content: Element<'_, Action>, id: window::Id, targets: Vec<Target>) -> Element<'_, Action> {
Element::new(DragArea { content, id, targets })
pub(super) fn area(
content: Element<'_, Action>,
id: window::Id,
targets: Vec<Target>,
) -> Element<'_, Action> {
Element::new(DragArea {
content,
id,
targets,
})
}
struct DragArea<'a> {
@@ -73,7 +81,11 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let (Some(target), Some(point)) = (target, point)
&& let Some((window, _)) = target.windows.iter().rev().find(|(_, rect)| rect.contains(point))
&& let Some((window, _)) = target
.windows
.iter()
.rev()
.find(|(_, rect)| rect.contains(point))
{
state.pressed = Some((*window, target.workspace, point));
state.moved = false;
@@ -97,7 +109,8 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
target.workspace,
)));
} else if !state.moved && target.workspace == source {
shell.publish(Action::App(Message::HubWorkspaceSelect(self.id, source)));
shell
.publish(Action::App(Message::HubWorkspaceSelect(self.id, source)));
}
}
state.moved = false;
@@ -133,9 +146,15 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn a11y_nodes(
&self,
@@ -143,7 +162,9 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn mouse_interaction(
&self,
@@ -156,9 +177,13 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
if tree.state.downcast_ref::<State>().pressed.is_some() {
return mouse::Interaction::Grabbing;
}
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
@@ -179,9 +204,13 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Action, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -231,9 +260,20 @@ mod tests {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
(15., 25.),
),
(Event::Mouse(mouse::Event::CursorMoved { position: end.into() }), end),
(Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), end),
(Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), end),
(
Event::Mouse(mouse::Event::CursorMoved {
position: end.into(),
}),
end,
),
(
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
end,
),
(
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
end,
),
] {
view.as_widget_mut().update(
&mut tree,
@@ -19,7 +19,12 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Thumbnail {
Size::new(Length::Fixed(44.), Length::Fixed(17.))
}
fn layout(&mut self, _: &mut widget::Tree, _: &cosmic::Renderer, limits: &layout::Limits) -> layout::Node {
fn layout(
&mut self,
_: &mut widget::Tree,
_: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::Node::new(limits.resolve(44., 17., Size::ZERO))
}
+87 -20
View File
@@ -40,7 +40,10 @@ fn select(mut workspaces: Vec<&WorkspaceSnapshot>, active: u64, budget: f32) ->
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
let count = workspaces.len();
if count == 0 || (count <= LIMIT && count as f32 * SLOT + ACTIVE_EXTRA - DOT_INSET <= budget) {
return Selection { workspaces, hidden: 0 };
return Selection {
workspaces,
hidden: 0,
};
}
let overflow_width = 6. + (count.to_string().len() + 1) as f32 * 7.;
let visible = (((budget - overflow_width - ACTIVE_EXTRA + DOT_INSET) / SLOT)
@@ -127,7 +130,9 @@ impl FereseShell {
.and_then(|entry| entry.size),
);
select(
self.snapshot.workspaces_for_output(Some(output.id)).collect(),
self.snapshot
.workspaces_for_output(Some(output.id))
.collect(),
output.active_workspace,
// Keep the compact strip within its existing width budget.
(panel.morph.expanded_width.min(available) - 28. - 5. * 32. - 4.).max(0.) as f32,
@@ -148,7 +153,9 @@ impl FereseShell {
let width = if width > 0. { width + 10. } else { 0. };
let mut runtime = runtime.borrow_mut();
if runtime.observed_workspace.is_none() {
runtime.observed_workspace = self.output_for_bar(entry.bar).map(|output| output.active_workspace);
runtime.observed_workspace = self
.output_for_bar(entry.bar)
.map(|output| output.active_workspace);
}
if runtime.workspace_width == width {
return None;
@@ -157,7 +164,11 @@ impl FereseShell {
matches!(runtime.hub.mode(), Mode::Idle).then_some(entry.bar)
})
.collect();
Task::batch(changes.into_iter().map(|id| self.transition_hub(id, Event::Resize)))
Task::batch(
changes
.into_iter()
.map(|id| self.transition_hub(id, Event::Resize)),
)
}
pub(crate) fn open_hub_overview(&mut self, id: window::Id) -> Task<Message> {
@@ -183,11 +194,18 @@ impl FereseShell {
.then_some(entry.bar)
})
.collect();
Task::batch(changed.into_iter().map(|id| self.show_hub_workspace_feedback(id)))
Task::batch(
changed
.into_iter()
.map(|id| self.show_hub_workspace_feedback(id)),
)
}
fn show_hub_workspace_feedback(&mut self, id: window::Id) -> Task<Message> {
if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. || self.hub_priority().is_some() {
if self.bar_hidden(id)
|| self.hub_workspace_width(id) == 0.
|| self.hub_priority().is_some()
{
return Task::none();
}
let Some(runtime) = self
@@ -218,11 +236,19 @@ impl FereseShell {
])
}
pub(crate) fn hub_workspace_feedback_expired(&mut self, id: window::Id, revision: Instant) -> Task<Message> {
pub(crate) fn hub_workspace_feedback_expired(
&mut self,
id: window::Id,
revision: Instant,
) -> Task<Message> {
self.transition_hub(id, Event::WorkspacesExpired(revision))
}
pub(crate) fn scroll_hub_workspaces(&mut self, id: window::Id, delta: ScrollDelta) -> Task<Message> {
pub(crate) fn scroll_hub_workspaces(
&mut self,
id: window::Id,
delta: ScrollDelta,
) -> Task<Message> {
if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. {
return Task::none();
}
@@ -234,14 +260,21 @@ impl FereseShell {
else {
return Task::none();
};
let Some(direction) = runtime.borrow_mut().workspace_scroll.step(delta, Instant::now()) else {
let Some(direction) = runtime
.borrow_mut()
.workspace_scroll
.step(delta, Instant::now())
else {
return Task::none();
};
let Some(output) = self.output_for_bar(id) else {
return Task::none();
};
// Include the trailing empty workspace so scrolling can create a new working space.
let mut workspaces: Vec<_> = self.snapshot.workspaces_for_output(Some(output.id)).collect();
let mut workspaces: Vec<_> = self
.snapshot
.workspaces_for_output(Some(output.id))
.collect();
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
if let Some(position) = workspaces
.iter()
@@ -256,7 +289,11 @@ impl FereseShell {
self.dismiss_workspace_tooltip_for_bar(id)
}
pub(crate) fn hub_workspaces(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
pub(crate) fn hub_workspaces(
&self,
id: window::Id,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let selection = self.hub_workspace_selection(id);
let width = selection.width();
let output = self.output_for_bar(id);
@@ -297,7 +334,9 @@ impl FereseShell {
if urgent {
label.push_str(&format!(" · Workspace {} needs attention", workspace.index));
}
let transition = self.workspace_ui.transition(workspace.id, workspace.id == active);
let transition = self
.workspace_ui
.transition(workspace.id, workspace.id == active);
dots = dots.push(motion::geometry_driven(
transition.revision(),
move |now| {
@@ -395,7 +434,12 @@ mod tests {
.collect();
for active in [1, 8, 20] {
let selection = select(workspaces.iter().collect(), active, 200.);
assert!(selection.workspaces.iter().any(|workspace| workspace.id == active));
assert!(
selection
.workspaces
.iter()
.any(|workspace| workspace.id == active)
);
assert!(
selection
.workspaces
@@ -406,7 +450,12 @@ mod tests {
assert_eq!(selection.hidden, if active == 20 { 16 } else { 15 });
let narrow = select(workspaces.iter().collect(), active, 148.);
assert!(narrow.width() <= 148.);
assert!(narrow.workspaces.iter().any(|workspace| workspace.id == active));
assert!(
narrow
.workspaces
.iter()
.any(|workspace| workspace.id == active)
);
}
}
@@ -416,11 +465,29 @@ mod tests {
let start = Instant::now();
let pixels = |y| ScrollDelta::Pixels { x: 0., y };
assert_eq!(scroll.step(pixels(-20.), start), None);
assert_eq!(scroll.step(pixels(-20.), start + Duration::from_millis(10)), Some(1));
assert_eq!(scroll.step(pixels(-100.), start + Duration::from_millis(20)), None);
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(200)), None);
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(500)), None);
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(510)), Some(-1));
assert_eq!(scroll.step(pixels(f32::NAN), start + Duration::from_secs(1)), None);
assert_eq!(
scroll.step(pixels(-20.), start + Duration::from_millis(10)),
Some(1)
);
assert_eq!(
scroll.step(pixels(-100.), start + Duration::from_millis(20)),
None
);
assert_eq!(
scroll.step(pixels(20.), start + Duration::from_millis(200)),
None
);
assert_eq!(
scroll.step(pixels(20.), start + Duration::from_millis(500)),
None
);
assert_eq!(
scroll.step(pixels(20.), start + Duration::from_millis(510)),
Some(-1)
);
assert_eq!(
scroll.step(pixels(f32::NAN), start + Duration::from_secs(1)),
None
);
}
}
+18 -7
View File
@@ -1,7 +1,9 @@
use std::time::{Duration, Instant};
use cosmic::app::Task;
use cosmic::iced::platform_specific::shell::commands::layer_surface::{set_exclusive_zone, set_input_zone, set_size};
use cosmic::iced::platform_specific::shell::commands::layer_surface::{
set_exclusive_zone, set_input_zone, set_size,
};
use cosmic::iced::window;
use ferese_panel::adaptive::{Adaptive, WindowState};
use ferese_panel::{Panel, PanelStyle};
@@ -135,10 +137,11 @@ impl FereseShell {
let mut tasks = Vec::new();
let mut changed = Vec::new();
for entry in &mut self.outputs {
if entry
.runtime
.observe(&self.config.panels[0], &self.snapshot, entry.name.as_deref())
{
if entry.runtime.observe(
&self.config.panels[0],
&self.snapshot,
entry.name.as_deref(),
) {
let panel = entry.runtime.panel(&self.config.panels[0]);
let geometry = panel.resolved_geometry();
let output = self
@@ -150,7 +153,11 @@ impl FereseShell {
entry.bar_regions.clear();
entry.panel_resolution = None;
tasks.push(set_input_zone(entry.bar, Some(Vec::new())));
tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32)));
tasks.push(set_size(
entry.bar,
None,
Some(geometry.height.round() as u32),
));
tasks.push(set_exclusive_zone(
entry.bar,
panel.exclusive_zone(output_bar_hidden(&self.snapshot, output)),
@@ -171,7 +178,11 @@ impl FereseShell {
for bar in changed {
tasks.push(self.update_panel_margin(bar, true));
tasks.push(self.dismiss_workspace_tooltip_for_bar(bar));
if self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == bar) {
if self
.menu
.as_ref()
.is_some_and(|menu| menu.anchor.parent == bar)
{
tasks.push(self.destroy_menu());
}
}
+37 -10
View File
@@ -157,7 +157,9 @@ impl Recorder {
match event {
Event::Update(update) => match update.state.as_str() {
"selecting" if self.control.is_some() => self.state = State::Selecting,
"recording" if self.control.is_some() && !matches!(self.state, State::Recording(_)) => {
"recording"
if self.control.is_some() && !matches!(self.state, State::Recording(_)) =>
{
self.state = State::Recording(Instant::now());
}
"saving" => self.state = State::Saving,
@@ -168,11 +170,17 @@ impl Recorder {
.unwrap_or_else(|| State::Error("Recorder returned no saved file".into()))
}
"cancelled" => self.state = State::Idle,
"error" => self.state = State::Error(update.message.unwrap_or_else(|| "Recording failed".into())),
"error" => {
self.state =
State::Error(update.message.unwrap_or_else(|| "Recording failed".into()))
}
_ => (),
},
Event::Exited => {
if matches!(self.state, State::Selecting | State::Recording(_) | State::Saving) {
if matches!(
self.state,
State::Selecting | State::Recording(_) | State::Saving
) {
self.state = State::Error("The recorder stopped unexpectedly".into());
}
self.control.take();
@@ -287,7 +295,11 @@ mod tests {
})
}
fn session() -> (Recorder, std::process::Child, tokio::sync::mpsc::Sender<Event>) {
fn session() -> (
Recorder,
std::process::Child,
tokio::sync::mpsc::Sender<Event>,
) {
// A real control pipe lets stop() exercise EOF without a recorder,
// portal, encoder, or D-Bus service.
let mut child = Command::new("cat")
@@ -330,7 +342,9 @@ mod tests {
recorder.apply(update("saving", None));
recorder.apply(update("saved", Some("display.webm".into())));
assert!(recorder.apply(Event::Exited));
assert!(matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("display.webm")));
assert!(
matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("display.webm"))
);
assert!(!recorder.busy());
assert_eq!(recorder.subscription().units(), 0);
assert!(send.try_send(Event::Exited).is_err());
@@ -394,10 +408,14 @@ mod tests {
assert!(!recorder.busy());
match terminal {
"saved" => {
assert!(matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("video.webm")))
assert!(
matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("video.webm"))
)
}
"cancelled" => assert!(matches!(recorder.state, State::Idle)),
_ => assert!(matches!(recorder.state, State::Error(ref message) if message == "Recording failed")),
_ => assert!(
matches!(recorder.state, State::Error(ref message) if message == "Recording failed")
),
}
assert!(send.try_send(Event::Exited).is_err());
}
@@ -407,8 +425,14 @@ mod tests {
fn missing_path_and_unexpected_exit_remain_errors() {
let mut recorder = Recorder::default();
recorder.apply(update("saved", None));
assert!(matches!(recorder.state, State::Error(ref message) if message == "Recorder returned no saved file"));
for state in [State::Selecting, State::Recording(Instant::now()), State::Saving] {
assert!(
matches!(recorder.state, State::Error(ref message) if message == "Recorder returned no saved file")
);
for state in [
State::Selecting,
State::Recording(Instant::now()),
State::Saving,
] {
recorder.state = state;
recorder.apply(Event::Exited);
assert!(
@@ -445,7 +469,10 @@ mod tests {
);
}
send.try_send(Event::Exited).unwrap();
assert!(matches!(stream.next().now_or_never(), Some(Some(Event::Exited))));
assert!(matches!(
stream.next().now_or_never(),
Some(Some(Event::Exited))
));
drop(send);
assert!(matches!(stream.next().now_or_never(), Some(None)));
}
+10 -2
View File
@@ -45,7 +45,11 @@ mod tests {
command.env("ICED_BACKEND", "tiny-skia,wgpu");
apply_app_backend(&mut command, None);
assert_eq!(
command.get_envs().find(|(key, _)| *key == "ICED_BACKEND").unwrap().1,
command
.get_envs()
.find(|(key, _)| *key == "ICED_BACKEND")
.unwrap()
.1,
None
);
}
@@ -56,7 +60,11 @@ mod tests {
let mut command = Command::new("ferese-settings");
apply_app_backend(&mut command, Some(OsStr::new(backend)));
assert_eq!(
command.get_envs().find(|(key, _)| *key == "ICED_BACKEND").unwrap().1,
command
.get_envs()
.find(|(key, _)| *key == "ICED_BACKEND")
.unwrap()
.1,
Some(OsStr::new(backend))
);
}
+104 -30
View File
@@ -159,15 +159,24 @@ impl Service {
let (updates, rx) = tokio::sync::watch::channel(None);
// Polling and writes have separate workers: a missing D-Bus service must
// never hold up volume/brightness changes. Publish only coherent polls.
let shared = Arc::new((Mutex::new((0u64, false, None::<ActionError>, false, 0)), Condvar::new()));
let shared = Arc::new((
Mutex::new((0u64, false, None::<ActionError>, false, 0)),
Condvar::new(),
));
let polling = shared.clone();
thread::spawn(move || {
let mut network = dbus_cache::Cache::with_wake("org.freedesktop.NetworkManager", polling.clone());
let mut network =
dbus_cache::Cache::with_wake("org.freedesktop.NetworkManager", polling.clone());
let mut bluetooth = dbus_cache::Cache::with_wake("org.bluez", polling.clone());
let mut battery_cache = dbus_cache::Cache::with_wake("org.freedesktop.UPower", polling.clone());
let mut profiles = dbus_cache::Cache::with_wake("org.freedesktop.UPower.PowerProfiles", polling.clone());
let mut legacy_profiles = dbus_cache::Cache::with_wake("net.hadess.PowerProfiles", polling.clone());
let mut battery_cache =
dbus_cache::Cache::with_wake("org.freedesktop.UPower", polling.clone());
let mut profiles = dbus_cache::Cache::with_wake(
"org.freedesktop.UPower.PowerProfiles",
polling.clone(),
);
let mut legacy_profiles =
dbus_cache::Cache::with_wake("net.hadess.PowerProfiles", polling.clone());
let mut login = dbus_cache::Cache::with_wake("org.freedesktop.login1", polling.clone());
let mut audio = audio_cache::Cache::new(polling.clone());
let mut hardware = hardware::Monitor::start(polling.clone()).ok();
@@ -191,7 +200,12 @@ impl Service {
.map(Some)
});
let fallback_profiles = legacy_profiles.read(before, |connection| {
read_power_profiles(connection, "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles").map(Some)
read_power_profiles(
connection,
"net.hadess.PowerProfiles",
"/net/hadess/PowerProfiles",
)
.map(Some)
});
let mut snapshot = Snapshot {
@@ -298,10 +312,12 @@ impl Service {
pub fn send(&mut self, action: Action) -> Result<(), ActionError> {
let generation = self.generation + 1;
let error_action = action.clone();
self.tx.try_send((generation, action)).map_err(|_| ActionError {
action: error_action,
message: "Controls are busy; please try again".to_owned(),
})?;
self.tx
.try_send((generation, action))
.map_err(|_| ActionError {
action: error_action,
message: "Controls are busy; please try again".to_owned(),
})?;
self.generation = generation;
Ok(())
@@ -328,7 +344,8 @@ pub fn available(program: &str) -> bool {
return executable(Path::new(program));
}
env::var_os("PATH").is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
env::var_os("PATH")
.is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
}
fn run(program: &str, args: &[&str]) -> Result<String, String> {
@@ -380,7 +397,10 @@ impl StatusBus {
}
}
fn query<T>(&mut self, query: impl FnOnce(&zbus::blocking::Connection) -> zbus::Result<T>) -> Option<T> {
fn query<T>(
&mut self,
query: impl FnOnce(&zbus::blocking::Connection) -> zbus::Result<T>,
) -> Option<T> {
if self.connection.is_none() {
let builder = if self.system {
zbus::blocking::connection::Builder::system()
@@ -388,7 +408,11 @@ impl StatusBus {
zbus::blocking::connection::Builder::session()
};
self.connection = builder.ok()?.method_timeout(Duration::from_secs(2)).build().ok();
self.connection = builder
.ok()?
.method_timeout(Duration::from_secs(2))
.build()
.ok();
}
match query(self.connection.as_ref()?) {
@@ -436,7 +460,9 @@ impl StatusBus {
}
}
fn read_capabilities(connection: &zbus::blocking::Connection) -> zbus::Result<Option<(bool, bool, bool)>> {
fn read_capabilities(
connection: &zbus::blocking::Connection,
) -> zbus::Result<Option<(bool, bool, bool)>> {
let can = |method| -> zbus::Result<bool> {
let value: String = connection
.call_method(
@@ -452,7 +478,11 @@ fn read_capabilities(connection: &zbus::blocking::Connection) -> zbus::Result<Op
Ok(value == "yes" || value == "challenge")
};
Ok(Some((can("CanPowerOff")?, can("CanReboot")?, can("CanSuspend")?)))
Ok(Some((
can("CanPowerOff")?,
can("CanReboot")?,
can("CanSuspend")?,
)))
}
fn read_battery(connection: &zbus::blocking::Connection) -> zbus::Result<Option<Battery>> {
@@ -500,7 +530,8 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
}
fn audio() -> Option<Audio> {
let (volume, muted) = parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
let (volume, muted) =
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
let info = run("wpctl", &["inspect", "@DEFAULT_AUDIO_SINK@"]).ok()?;
let output = info
.lines()
@@ -508,11 +539,17 @@ fn audio() -> Option<Audio> {
.map(|s| s.trim_matches('"').to_owned())
.unwrap_or_else(|| "Default output".into());
Some(Audio { volume, muted, output })
Some(Audio {
volume,
muted,
output,
})
}
fn read(path: &Path, name: &str) -> Option<String> {
fs::read_to_string(path.join(name)).ok().map(|s| s.trim().to_owned())
fs::read_to_string(path.join(name))
.ok()
.map(|s| s.trim().to_owned())
}
fn battery() -> Option<Battery> {
@@ -521,7 +558,10 @@ fn battery() -> Option<Battery> {
let mut batteries = entries
.flatten()
.map(|e| e.path())
.filter(|p| read(p, "type").as_deref() == Some("Battery") && read(p, "scope").as_deref() != Some("Device"))
.filter(|p| {
read(p, "type").as_deref() == Some("Battery")
&& read(p, "scope").as_deref() != Some("Device")
})
.collect::<Vec<_>>();
batteries.sort();
@@ -541,7 +581,13 @@ fn power_profiles(bus: &mut StatusBus) -> Option<PowerProfiles> {
"org.freedesktop.UPower.PowerProfiles",
"/org/freedesktop/UPower/PowerProfiles",
)
.or_else(|_| read_power_profiles(connection, "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles"))
.or_else(|_| {
read_power_profiles(
connection,
"net.hadess.PowerProfiles",
"/net/hadess/PowerProfiles",
)
})
})
}
@@ -552,8 +598,10 @@ fn read_power_profiles(
) -> zbus::Result<PowerProfiles> {
let proxy = zbus::blocking::Proxy::new(connection, destination, path, destination)?;
let active = proxy.get_property::<String>("ActiveProfile")?;
let profiles =
proxy.get_property::<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>("Profiles")?;
let profiles = proxy
.get_property::<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>(
"Profiles",
)?;
let names = ["power-saver", "balanced", "performance"];
let available = names.map(|name| {
profiles.iter().any(|profile| {
@@ -616,18 +664,30 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
),
Action::Mute(on) => run(
"wpctl",
&["set-mute", "@DEFAULT_AUDIO_SINK@", if *on { "1" } else { "0" }],
&[
"set-mute",
"@DEFAULT_AUDIO_SINK@",
if *on { "1" } else { "0" },
],
),
Action::Brightness(value) => run(
"brightnessctl",
&["--class=backlight", "set", &format!("{}%", value.clamp(&1, &100))],
&[
"--class=backlight",
"set",
&format!("{}%", value.clamp(&1, &100)),
],
),
Action::NightLight(enabled) => return night_light::Change::Enabled(*enabled).save(),
Action::NightLightTemperature(temperature) => {
return night_light::Change::Temperature(*temperature).save();
}
Action::Dnd(_) | Action::Notifications => return Err("Native notifications are unavailable".into()),
Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => {
Action::Dnd(_) | Action::Notifications => {
return Err("Native notifications are unavailable".into());
}
Action::PowerProfile(profile)
if matches!(*profile, "power-saver" | "balanced" | "performance") =>
{
run("powerprofilesctl", &["set", profile])
}
Action::PowerProfile(_) => return Err("Invalid power profile".to_owned()),
@@ -790,7 +850,10 @@ mod tests {
assert!(bus.can_power("CanReboot"));
assert!(!bus.can_power("CanSuspend"));
mode.store(1, Ordering::Relaxed);
assert!(!bus.can_power("CanPowerOff"), "capabilities must not be cached");
assert!(
!bus.can_power("CanPowerOff"),
"capabilities must not be cached"
);
assert!(bus.connection.is_some());
assert!(!bus.notification_service_owned());
server.request_name("org.erikreider.swaync").unwrap();
@@ -821,7 +884,12 @@ mod tests {
assert_eq!(brightness_percent(2.0, 3.0), Some(67));
assert_eq!(brightness_percent(0.0, 400.0), Some(0));
for (current, max) in [(1.0, 0.0), (-1.0, 100.0), (f64::NAN, 100.0), (1.0, f64::INFINITY)] {
for (current, max) in [
(1.0, 0.0),
(-1.0, 100.0),
(f64::NAN, 100.0),
(1.0, f64::INFINITY),
] {
assert_eq!(brightness_percent(current, max), None);
}
}
@@ -885,7 +953,10 @@ mod tests {
panic!("worker result missing");
};
assert_eq!(update.generation, 3);
assert!(matches!(stream.as_mut().poll_next(&mut context), Poll::Ready(None)));
assert!(matches!(
stream.as_mut().poll_next(&mut context),
Poll::Ready(None)
));
}
#[test]
@@ -906,7 +977,10 @@ mod tests {
state.1 = false;
shared.1.notify_one();
}
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap(), Some(8));
assert_eq!(
receiver.recv_timeout(Duration::from_secs(2)).unwrap(),
Some(8)
);
worker.join().unwrap();
}
+18 -4
View File
@@ -31,7 +31,12 @@ impl Cache {
self.read_at(action, Instant::now(), super::audio)
}
fn read_at(&mut self, action: u64, now: Instant, read: impl FnOnce() -> Option<Audio>) -> Option<Audio> {
fn read_at(
&mut self,
action: u64,
now: Instant,
read: impl FnOnce() -> Option<Audio>,
) -> Option<Audio> {
let revision = self.signals.revision();
// Without a live subscription, keep the old bounded polling fallback.
// A missing default sink retries slowly, even if the server is connected.
@@ -66,7 +71,10 @@ mod tests {
fn cache() -> Cache {
Cache {
signals: Signals::test(Arc::new((Mutex::new((0, false, None, false, 0)), Condvar::new()))),
signals: Signals::test(Arc::new((
Mutex::new((0, false, None, false, 0)),
Condvar::new(),
))),
revision: 0,
action: u64::MAX,
value: None,
@@ -89,7 +97,9 @@ mod tests {
assert_eq!(cache.read_at(0, now, sample).unwrap().volume, 48);
for second in 1..=120 {
let audio = cache.read_at(0, now + Duration::from_secs(second), || panic!("idle query"));
let audio = cache.read_at(0, now + Duration::from_secs(second), || {
panic!("idle query")
});
assert_eq!(audio.unwrap().volume, 48);
}
}
@@ -169,6 +179,10 @@ mod tests {
.read_at(0, now + Duration::from_secs(4), || panic!("early retry"))
.is_none()
);
assert!(cache.read_at(0, now + Duration::from_secs(5), sample).is_some());
assert!(
cache
.read_at(0, now + Duration::from_secs(5), sample)
.is_some()
);
}
}
@@ -129,13 +129,18 @@ struct Bound {
_proxy: Box<dyn ProxyT>,
}
fn parameter_events(state: Arc<State>) -> impl Fn(i32, ParamType, u32, u32, Option<&pw::spa::pod::Pod>) {
fn parameter_events(
state: Arc<State>,
) -> impl Fn(i32, ParamType, u32, u32, Option<&pw::spa::pod::Pod>) {
let values = RefCell::new(HashMap::<u32, Vec<u8>>::new());
move |_, _, index, _, param| {
let bytes = param.map_or(&[][..], pw::spa::pod::Pod::as_bytes);
let mut values = values.borrow_mut();
if values.get(&index).is_none_or(|previous| previous.as_slice() != bytes) {
if values
.get(&index)
.is_none_or(|previous| previous.as_slice() != bytes)
{
values.insert(index, bytes.to_vec());
state.changed();
}
@@ -165,20 +170,28 @@ fn bind(
}
ObjectType::Device if class == Some("Audio/Device") => {
let device: pw::device::Device = registry.bind(object)?;
let listener = device.add_listener_local().param(parameter_events(state)).register();
let listener = device
.add_listener_local()
.param(parameter_events(state))
.register();
device.subscribe_params(&[ParamType::Route]);
Bound {
_listener: Box::new(listener),
_proxy: Box::new(device),
}
}
ObjectType::Metadata if object.props.and_then(|props| props.get("metadata.name")) == Some("default") => {
ObjectType::Metadata
if object.props.and_then(|props| props.get("metadata.name")) == Some("default") =>
{
let metadata: pw::metadata::Metadata = registry.bind(object)?;
let listener = metadata
.add_listener_local()
.property(move |subject, key, _, _| {
if subject == 0
&& matches!(key, None | Some("default.audio.sink" | "default.configured.audio.sink"))
&& matches!(
key,
None | Some("default.audio.sink" | "default.configured.audio.sink")
)
{
state.changed();
}
@@ -199,7 +212,8 @@ fn bind(
fn monitor(state: Arc<State>, stop: &Stop, remote: Option<&str>) -> Result<(), pw::Error> {
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
let context = pw::context::ContextRc::new(&mainloop, None)?;
let properties = remote.map(|remote| pw::properties::properties! { *pw::keys::REMOTE_NAME => remote });
let properties =
remote.map(|remote| pw::properties::properties! { *pw::keys::REMOTE_NAME => remote });
let core = context.connect_rc(properties)?;
let registry = core.get_registry_rc()?;
let objects = Rc::new(RefCell::new(HashMap::new()));
@@ -324,12 +338,19 @@ context.objects = [
let mut server = Server(command.spawn().unwrap());
wait_for(|| root.path().join("pipewire-0").exists());
let wake = Arc::new((Mutex::new((0, false, None, false, 0)), Condvar::new()));
let signals = Signals::start_remote(wake, Some(root.path().join("pipewire-0").to_str().unwrap().into()));
let signals = Signals::start_remote(
wake,
Some(root.path().join("pipewire-0").to_str().unwrap().into()),
);
wait_for(|| signals.alive());
std::thread::sleep(Duration::from_millis(500));
let revision = signals.revision();
std::thread::sleep(Duration::from_secs(2));
assert_eq!(signals.revision(), revision, "idle audio emitted repeated updates");
assert_eq!(
signals.revision(),
revision,
"idle audio emitted repeated updates"
);
let run = |program: &str, args: &[&str]| {
let output = Command::new(program)
.args(args)
@@ -337,12 +358,22 @@ context.objects = [
.env("XDG_RUNTIME_DIR", root.path())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
output.stdout
};
run(
"pw-metadata",
&["-n", "default", "0", "default.audio.sink", r#"{"name":"test-sink"}"#],
&[
"-n",
"default",
"0",
"default.audio.sink",
r#"{"name":"test-sink"}"#,
],
);
wait_for(|| signals.revision() > revision);
let graph: serde_json::Value = serde_json::from_slice(&run("pw-dump", &[])).unwrap();
@@ -375,7 +406,12 @@ context.objects = [
let revision = signals.revision();
run(
"pw-cli",
&["set-param", &id, "Props", "{ channelVolumes = [ 0.125 0.125 ] }"],
&[
"set-param",
&id,
"Props",
"{ channelVolumes = [ 0.125 0.125 ] }",
],
);
wait_for(|| signals.revision() > revision);
let revision = signals.revision();
+22 -4
View File
@@ -37,7 +37,10 @@ pub(super) fn read(connection: &Connection) -> zbus::Result<Option<Bluetooth>> {
return Ok(None);
};
let Some((path, _)) = adapters.iter().find(|(_, properties)| {
properties.get("Address").and_then(|value| <&str>::try_from(value).ok()) == Some(address)
properties
.get("Address")
.and_then(|value| <&str>::try_from(value).ok())
== Some(address)
}) else {
return Ok(None);
};
@@ -93,9 +96,24 @@ mod tests {
);
}
for (path, adapter, connected, alias) in [
("/org/bluez/hci0/dev_a", "/org/bluez/hci0", true, "Headphones"),
("/org/bluez/hci0/dev_b", "/org/bluez/hci0", false, "Keyboard"),
("/org/bluez/hci1/dev_a", "/org/bluez/hci1", true, "Other adapter"),
(
"/org/bluez/hci0/dev_a",
"/org/bluez/hci0",
true,
"Headphones",
),
(
"/org/bluez/hci0/dev_b",
"/org/bluez/hci0",
false,
"Keyboard",
),
(
"/org/bluez/hci1/dev_a",
"/org/bluez/hci1",
true,
"Other adapter",
),
] {
graph.insert(
OwnedObjectPath::try_from(path).unwrap(),
+51 -11
View File
@@ -16,7 +16,11 @@ struct Signals {
}
impl Signals {
fn new(connection: &Connection, service: &str, wake: Option<Arc<PollState>>) -> zbus::Result<Self> {
fn new(
connection: &Connection,
service: &str,
wake: Option<Arc<PollState>>,
) -> zbus::Result<Self> {
// Install both rules before the snapshot, including when the service is
// currently absent. Owner changes invalidate paths after a restart.
let changes = MessageIterator::for_match_rule(
@@ -45,7 +49,9 @@ impl Signals {
let (stop, stopped) = oneshot::channel::<()>();
std::thread::spawn(move || {
// Enter a runtime for zbus stream cleanup, including cancellation.
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
if let Ok(runtime) = runtime {
// Dropping the blocking wrappers enters zbus' runtime, so do
// this before entering our async runtime.
@@ -120,8 +126,16 @@ impl<T: Clone> Cache<T> {
(self.action != u64::MAX && (self.signals.is_none() || self.retry)).then_some(self.retry_at)
}
pub(super) fn read(&mut self, action: u64, read: fn(&Connection) -> zbus::Result<Option<T>>) -> Option<T> {
if self.signals.as_ref().is_some_and(|s| !s.alive.load(Ordering::Acquire)) {
pub(super) fn read(
&mut self,
action: u64,
read: fn(&Connection) -> zbus::Result<Option<T>>,
) -> Option<T> {
if self
.signals
.as_ref()
.is_some_and(|s| !s.alive.load(Ordering::Acquire))
{
self.signals = None;
self.bus.connection = None;
self.value = None;
@@ -135,7 +149,10 @@ impl<T: Clone> Cache<T> {
}
// If subscription setup fails, read fresh rather than trusting a cache
// with no invalidation. Retry setup at the bounded recovery deadline.
let revision = self.signals.as_ref().map(|s| s.revision.load(Ordering::Acquire));
let revision = self
.signals
.as_ref()
.map(|s| s.revision.load(Ordering::Acquire));
if (revision.is_none() && Instant::now() >= self.retry_at)
|| revision.is_some_and(|revision| revision != self.revision)
|| action != self.action
@@ -211,10 +228,19 @@ mod tests {
assert_eq!(cache.read(0, read_counter), Some(1));
}
assert_eq!(count.load(Ordering::Relaxed), 1);
assert!(cache.next_retry().is_none(), "healthy subscriptions must not poll");
assert!(
cache.next_retry().is_none(),
"healthy subscriptions must not poll"
);
assert_eq!(cache.read(1, read_counter), Some(2));
server
.emit_signal(None::<&str>, "/test", "org.ferese.StatusCounter", "Changed", &())
.emit_signal(
None::<&str>,
"/test",
"org.ferese.StatusCounter",
"Changed",
&(),
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(2);
while cache.read(1, read_counter) != Some(3) {
@@ -227,7 +253,10 @@ mod tests {
assert!(Instant::now() < deadline, "owner loss kept stale data");
std::thread::sleep(Duration::from_millis(1));
}
assert!(cache.next_retry().is_none(), "absent services wait for owner changes");
assert!(
cache.next_retry().is_none(),
"absent services wait for owner changes"
);
server.request_name("org.ferese.StatusCounter").unwrap();
let deadline = Instant::now() + Duration::from_secs(2);
while cache.read(1, read_counter).is_none() {
@@ -270,13 +299,24 @@ mod tests {
};
let before = signals.revision.load(Ordering::Acquire);
server
.emit_signal(None::<&str>, "/test", "org.ferese.StatusTest", "Changed", &())
.emit_signal(
None::<&str>,
"/test",
"org.ferese.StatusTest",
"Changed",
&(),
)
.unwrap();
let (state, timeout) = wake
.1
.wait_timeout_while(wake.0.lock().unwrap(), Duration::from_secs(2), |state| state.4 == 0)
.wait_timeout_while(wake.0.lock().unwrap(), Duration::from_secs(2), |state| {
state.4 == 0
})
.unwrap();
assert!(!timeout.timed_out(), "service signal must wake the status worker");
assert!(
!timeout.timed_out(),
"service signal must wake the status worker"
);
assert!(state.4 > 0);
drop(state);
wait(before);
+5 -2
View File
@@ -73,12 +73,15 @@ impl Monitor {
loop {
// SAFETY: both descriptors remain owned by this thread; poll writes within the array.
let result = unsafe { libc::poll(descriptors.as_mut_ptr(), 2, -1) };
if result < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
if result < 0
&& std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted
{
continue;
}
if result < 0
|| descriptors[1].revents != 0
|| descriptors[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0
|| descriptors[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL)
!= 0
{
break;
}
+6 -1
View File
@@ -123,7 +123,12 @@ mod tests {
impl Wireless {
#[zbus(property)]
fn active_access_point(&self) -> OwnedObjectPath {
OwnedObjectPath::try_from(if self.0.lock().unwrap().active { "/ap" } else { "/" }).unwrap()
OwnedObjectPath::try_from(if self.0.lock().unwrap().active {
"/ap"
} else {
"/"
})
.unwrap()
}
}
struct Ap(Arc<Mutex<State>>);
+8 -3
View File
@@ -51,19 +51,24 @@ impl Change {
pub(super) fn save(self) -> Result<(), String> {
let path = ferese_config::config_path().ok_or("No configuration directory")?;
let file = ferese_config::file::ConfigFile::open(&path)?;
let mut document = ferese_config::Document::parse(file.source()).map_err(|e| e.to_string())?;
let mut document =
ferese_config::Document::parse(file.source()).map_err(|e| e.to_string())?;
let (field, value) = match self {
Self::Enabled(value) => ("night_light.enabled", json!(value)),
Self::Temperature(value) => ("night_light.temperature", json!(value)),
};
document.set(field, value).map_err(|e| e.to_string())?;
let settings: ferese_config::night_light::Settings =
serde_json::from_value(document.value()["night_light"].clone()).map_err(|e| e.to_string())?;
serde_json::from_value(document.value()["night_light"].clone())
.map_err(|e| e.to_string())?;
settings.validate()?;
file.replace(&document.to_string(), None, |staged| {
run(
"ferese",
&["--check-config", staged.to_str().ok_or("Invalid configuration path")?],
&[
"--check-config",
staged.to_str().ok_or("Invalid configuration path")?,
],
)
.map(|_| ())
})?;
+10 -4
View File
@@ -21,8 +21,8 @@ pub(super) fn view<'a>(
opacity: p,
} = style;
if let Some(a) = &shell.status.audio {
let mut volume_heading =
row![shell.render.text("Volume").size(13).width(Length::Fill)].align_y(Alignment::Center);
let mut volume_heading = row![shell.render.text("Volume").size(13).width(Length::Fill)]
.align_y(Alignment::Center);
if combined {
volume_heading = volume_heading.push(shell_switch(
shell.render,
@@ -35,7 +35,11 @@ pub(super) fn view<'a>(
}
let audio = column![
volume_heading,
shell.render.text(&a.output).size(12).class(theme::Text::Color(muted)),
shell
.render
.text(&a.output)
.size(12)
.class(theme::Text::Color(muted)),
slider_row(
shell.render,
audio_icon(a.volume, a.muted),
@@ -62,7 +66,9 @@ pub(super) fn heading<'a>(
mut heading: super::MenuHeading<'a>,
style: MenuStyle,
) -> super::MenuHeading<'a> {
let MenuStyle { theme, opacity: p, .. } = style;
let MenuStyle {
theme, opacity: p, ..
} = style;
if let Some(audio) = &shell.status.audio {
heading = heading.push(shell_switch(
shell.render,
+47 -24
View File
@@ -5,7 +5,9 @@ use cosmic::{Element, theme};
use super::Menu;
use crate::status::Snapshot;
use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording};
use crate::{
BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording,
};
impl FereseShell {
pub(crate) fn view_media_item(
@@ -48,8 +50,9 @@ impl FereseShell {
} else {
status_icon(kind, &self.status)
};
let selected =
selected || (kind == Menu::Recording && matches!(self.recorder.state, recording::State::Recording(_)));
let selected = selected
|| (kind == Menu::Recording
&& matches!(self.recorder.state, recording::State::Recording(_)));
let foreground = color(if selected {
theme.accent
} else if enabled {
@@ -82,20 +85,17 @@ impl FereseShell {
.as_ref()
.is_some_and(|n| n.count > 0 && !n.dnd)
{
content = content.push(
container(self.render.text(""))
.width(4)
.height(4)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(foreground)),
border: Border {
shape: BorderShape::Continuous,
radius: self.render.radius(2.0).into(),
..Default::default()
},
content = content.push(container(self.render.text("")).width(4).height(4).class(
theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(foreground)),
border: Border {
shape: BorderShape::Continuous,
radius: self.render.radius(2.0).into(),
..Default::default()
})),
);
},
..Default::default()
}),
));
}
let content = bar_content(content, metrics.group_item_height);
let recording_busy = kind == Menu::Recording && self.recorder.busy();
@@ -107,7 +107,10 @@ impl FereseShell {
} else {
status_label(kind, &self.status)
})
.padding([0.0, ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0)])
.padding([
0.0,
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
])
.height(metrics.group_item_height)
.on_press_with_rectangle(move |offset, bounds| {
if kind == Menu::Recording {
@@ -134,7 +137,10 @@ impl FereseShell {
fn status_label(kind: Menu, s: &Snapshot) -> String {
match kind {
Menu::Network => match &s.network {
Some(n) if n.enabled => format!("Wi-Fi: {}", n.connection.as_deref().unwrap_or("not connected")),
Some(n) if n.enabled => format!(
"Wi-Fi: {}",
n.connection.as_deref().unwrap_or("not connected")
),
_ => "Wi-Fi: off".into(),
},
Menu::Bluetooth => match &s.bluetooth {
@@ -204,7 +210,9 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
}
}
Menu::Bluetooth => match &s.bluetooth {
Some(b) if b.enabled && !b.devices.is_empty() => (ferese_theme::icons::BLUETOOTH_CONNECTED, true),
Some(b) if b.enabled && !b.devices.is_empty() => {
(ferese_theme::icons::BLUETOOTH_CONNECTED, true)
}
Some(b) if b.enabled => (ferese_theme::icons::BLUETOOTH_ON, true),
_ => (ferese_theme::icons::BLUETOOTH_OFF, false),
},
@@ -261,26 +269,41 @@ mod tests {
}),
..Default::default()
};
assert_eq!(status_icon(Menu::Audio, &s), (ferese_theme::icons::VOLUME_MUTE, false));
assert_eq!(
status_icon(Menu::Audio, &s),
(ferese_theme::icons::VOLUME_MUTE, false)
);
s.battery = Some(status::Battery {
percent: 2,
status: "Charging".into(),
});
assert_eq!(status_icon(Menu::Battery, &s).0, ferese_theme::icons::BATTERY_CHARGING);
assert_eq!(
status_icon(Menu::Battery, &s).0,
ferese_theme::icons::BATTERY_CHARGING
);
s.notifications = Some(status::Notifications { count: 3, dnd: true });
s.notifications = Some(status::Notifications {
count: 3,
dnd: true,
});
assert_eq!(
status_icon(Menu::Notifications, &s).0,
ferese_theme::icons::NOTIFICATIONS_OFF
);
assert_eq!(status_label(Menu::Notifications, &s), "Notifications: do not disturb");
assert_eq!(
status_label(Menu::Notifications, &s),
"Notifications: do not disturb"
);
s.bluetooth = Some(status::Bluetooth {
enabled: false,
devices: vec!["Headphones".into()],
});
assert_eq!(status_icon(Menu::Bluetooth, &s).0, ferese_theme::icons::BLUETOOTH_OFF);
assert_eq!(
status_icon(Menu::Bluetooth, &s).0,
ferese_theme::icons::BLUETOOTH_OFF
);
}
#[test]
+44 -8
View File
@@ -7,7 +7,11 @@ use super::{MenuRows, MenuStyle};
use crate::status::Action;
use crate::{FereseShell, Message, ShellTheme, bar_icon, color_with_opacity, motion};
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
pub(super) fn view<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
) -> MenuRows<'a> {
let MenuStyle {
theme,
primary: _,
@@ -37,9 +41,17 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
if let Some(profiles) = &shell.status.power_profiles {
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
for (index, (label, icon, profile)) in [
("Power saver", ferese_theme::icons::POWER_SAVER, "power-saver"),
(
"Power saver",
ferese_theme::icons::POWER_SAVER,
"power-saver",
),
("Balanced", ferese_theme::icons::POWER_BALANCED, "balanced"),
("Performance", ferese_theme::icons::POWER_PERFORMANCE, "performance"),
(
"Performance",
ferese_theme::icons::POWER_PERFORMANCE,
"performance",
),
]
.into_iter()
.enumerate()
@@ -89,18 +101,33 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
),
] {
if enabled {
power = power.push(power_tile_action(shell.render, label, icon, action, theme, p));
power = power.push(power_tile_action(
shell.render,
label,
icon,
action,
theme,
p,
));
}
}
if shell.status.reboot || shell.status.poweroff || shell.status.suspend {
rows = rows.push(ferese_theme::menus::section_label("Power", shell.render.font, muted));
rows = rows.push(ferese_theme::menus::section_label(
"Power",
shell.render.font,
muted,
));
rows = rows.push(power);
}
rows
}
pub(super) fn brightness<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
pub(super) fn brightness<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
) -> MenuRows<'a> {
let MenuStyle {
theme,
primary,
@@ -135,7 +162,14 @@ fn power_tile<'a>(
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let foreground = color_with_opacity(if selected { theme.accent } else { theme.text_primary }, opacity);
let foreground = color_with_opacity(
if selected {
theme.accent
} else {
theme.text_primary
},
opacity,
);
let tile = container(
column![bar_icon(icon, 19, foreground), render.text(label).size(11)]
.spacing(3)
@@ -146,7 +180,9 @@ fn power_tile<'a>(
let btn = button::custom(tile)
.width(Length::FillPortion(1))
.padding([8, 2])
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(profile))));
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(
profile,
))));
motion::button(render, btn, foreground, selected, opacity)
}
+11 -2
View File
@@ -14,7 +14,12 @@ fn device_list_height(count: usize) -> Option<f32> {
(count > 4).then_some(DEVICE_LIST_HEIGHT)
}
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle, in_hub: bool) -> MenuRows<'a> {
pub(super) fn view<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
in_hub: bool,
) -> MenuRows<'a> {
let MenuStyle { muted, .. } = style;
if let Some(b) = &shell.status.bluetooth {
rows = rows.push(status_summary(
@@ -43,7 +48,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
devices = devices.push(
column![
shell.render.text(device).size(14),
shell.render.text("Connected").size(12).class(theme::Text::Color(muted)),
shell
.render
.text("Connected")
.size(12)
.class(theme::Text::Color(muted)),
]
.spacing(3),
);
+10 -5
View File
@@ -35,11 +35,16 @@ pub(super) fn hub<'a>(
let navigation = |label: &'static str, message: Message| {
crate::motion::button(
render,
cosmic::widget::button::custom(render.text(label).size(11).class(cosmic::theme::Text::Color(primary)))
.name(label)
.padding([2, 4])
.height(22)
.on_press(cosmic::Action::App(message)),
cosmic::widget::button::custom(
render
.text(label)
.size(11)
.class(cosmic::theme::Text::Color(primary)),
)
.name(label)
.padding([2, 4])
.height(22)
.on_press(cosmic::Action::App(message)),
primary,
false,
opacity,
+20 -9
View File
@@ -30,7 +30,11 @@ pub(super) fn status_summary<'a>(
.center_y(32)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
a: if enabled { 0.22 * opacity } else { 0.06 * opacity },
a: if enabled {
0.22 * opacity
} else {
0.06 * opacity
},
..badge_color
})),
border: Border {
@@ -58,7 +62,10 @@ pub(super) fn status_summary<'a>(
badge,
column![
render.text(title).size(14),
render.text(subtitle).size(12).class(theme::Text::Color(muted)),
render
.text(subtitle)
.size(12)
.class(theme::Text::Color(muted)),
]
.spacing(4)
.width(Length::Fill)
@@ -176,13 +183,17 @@ pub(super) fn slider_row(
icon_accent: Color,
opacity: f32,
) -> Element<'static, cosmic::Action<Message>> {
let control = slider(if brightness { 1..=100 } else { 0..=100 }, value, move |value| {
cosmic::Action::App(Message::Control(if brightness {
Action::Brightness(value)
} else {
Action::Volume(value)
}))
})
let control = slider(
if brightness { 1..=100 } else { 0..=100 },
value,
move |value| {
cosmic::Action::App(Message::Control(if brightness {
Action::Brightness(value)
} else {
Action::Volume(value)
}))
},
)
.width(Length::Fill)
.height(24)
.class(ferese_theme::menus::slider(foreground, opacity));
+37 -10
View File
@@ -10,7 +10,11 @@ use super::{Menu, OpenMenu, PopoverAnchor};
use crate::status::Action;
use crate::{FereseShell, Message};
fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_panel::Edge) -> SctkPositioner {
fn menu_positioner(
rectangle: Rectangle<i32>,
height_limit: f32,
edge: ferese_panel::Edge,
) -> SctkPositioner {
// xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars.
let (anchor, gravity, gap) = match edge {
ferese_panel::Edge::Top => (2u32, 6u32, 8),
@@ -21,7 +25,9 @@ fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_pa
anchor: anchor.try_into().unwrap(),
gravity: gravity.try_into().unwrap(),
offset: (rectangle.width / 2, gap),
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
size_limits: Limits::NONE
.max_width(368.0)
.max_height(720.0_f32.max(height_limit)),
constraint_adjustment: 3,
..Default::default()
}
@@ -41,7 +47,10 @@ impl FereseShell {
pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task<Message> {
if (self.snapshot.binding_mode.is_some() && anchor.item.is_some())
|| !anchor.belongs_to(&self.config.panels[0])
|| !self.outputs.iter().any(|output| output.bar == anchor.parent)
|| !self
.outputs
.iter()
.any(|output| output.bar == anchor.parent)
|| self.bar_hidden(anchor.parent)
|| !kind.available(&self.status)
{
@@ -60,7 +69,9 @@ impl FereseShell {
return close.chain(open);
}
if self.menu.as_ref().is_some_and(|menu| {
menu.kind == kind && menu.anchor.same_item(&anchor) && menu.anchor.rectangle == anchor.rectangle
menu.kind == kind
&& menu.anchor.same_item(&anchor)
&& menu.anchor.rectangle == anchor.rectangle
}) {
if let Some(menu) = &mut self.menu
&& menu.motion.closing()
@@ -94,7 +105,11 @@ impl FereseShell {
if !kind.available(&self.status) {
return Task::none();
}
if self.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Notifications) {
if self
.menu
.as_ref()
.is_some_and(|menu| menu.kind == Menu::Notifications)
{
self.notifications.history_open = false;
self.notifications.hovered = None;
}
@@ -120,7 +135,9 @@ impl FereseShell {
menu.switch_panel(kind, self.config.animations, Instant::now());
return Task::batch([
notifications,
cosmic::iced::platform_specific::shell::commands::popup::reposition(menu.id, positioner),
cosmic::iced::platform_specific::shell::commands::popup::reposition(
menu.id, positioner,
),
]);
}
let id = window::Id::unique();
@@ -166,7 +183,9 @@ impl FereseShell {
}
Task::batch([
tooltip,
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(menu.id))),
cosmic::task::message(cosmic::Action::Surface(
cosmic::surface::action::destroy_popup(menu.id),
)),
self.sync_notification_surface(),
])
}
@@ -302,7 +321,9 @@ mod tests {
width: 32,
height: 30,
};
for (edge, anchor, gravity, gap) in [(Edge::Top, 2u32, 6u32, 8), (Edge::Bottom, 1u32, 5u32, -8)] {
for (edge, anchor, gravity, gap) in
[(Edge::Top, 2u32, 6u32, 8), (Edge::Bottom, 1u32, 5u32, -8)]
{
let positioner = menu_positioner(rectangle, 620., edge);
assert_eq!(positioner.anchor_rect, rectangle);
assert_eq!(positioner.anchor, anchor.try_into().unwrap());
@@ -408,7 +429,10 @@ mod tests {
},
now,
);
assert_eq!(menu.motion.progress_at(now + Duration::from_millis(60)), normal);
assert_eq!(
menu.motion.progress_at(now + Duration::from_millis(60)),
normal
);
}
#[test]
fn popup_anchors_reject_removed_hidden_items_and_other_panel_definitions() {
@@ -426,7 +450,10 @@ mod tests {
};
assert!(anchor.belongs_to(&panel));
let items = &mut panel.end.groups[0].items;
let network = items.iter().position(|item| item.id.0 == "network").unwrap();
let network = items
.iter()
.position(|item| item.id.0 == "network")
.unwrap();
items[network].visible = false;
assert!(!anchor.belongs_to(&panel));
panel.end.groups[0].items.remove(network);
+177 -92
View File
@@ -8,7 +8,8 @@ use serde_json::json;
use super::{Menu, MenuRows, MenuStyle};
use crate::{
BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion,
BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color,
color_with_opacity, motion,
};
const SEEK_THUMB_DIAMETER: f32 = 10.0;
@@ -80,30 +81,39 @@ pub(super) fn bar<'a>(
let Some(player) = &shell.media.snapshot.selected else {
return shell.render.text("").into();
};
let foreground = color(if selected { palette.accent } else { palette.text_primary });
let vertical_padding = ((metrics.group_item_height - f32::from(metrics.icon_size)) * 0.5).clamp(0.0, 2.0);
let foreground = color(if selected {
palette.accent
} else {
palette.text_primary
});
let vertical_padding =
((metrics.group_item_height - f32::from(metrics.icon_size)) * 0.5).clamp(0.0, 2.0);
let height = metrics.group_item_height - 2.0 * vertical_padding;
let side = height.min(24.0);
let cover: Element<'_, cosmic::Action<Message>> =
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
Some(handle) => image(handle.clone())
.width(side)
.height(side)
.content_fit(cosmic::iced::ContentFit::Cover)
.border_radius(shell.render.radius(6.0))
.shape(BorderShape::Continuous)
.into(),
None => container(accented_icon(
icons::MEDIA,
side as u16,
foreground,
color(palette.accent),
))
.center_x(side)
.center_y(side)
let cover: Element<'_, cosmic::Action<Message>> = match shell
.media
.artwork
.as_ref()
.and_then(|art| art.handle.as_ref())
{
Some(handle) => image(handle.clone())
.width(side)
.height(side)
.content_fit(cosmic::iced::ContentFit::Cover)
.border_radius(shell.render.radius(6.0))
.shape(BorderShape::Continuous)
.into(),
};
None => container(accented_icon(
icons::MEDIA,
side as u16,
foreground,
color(palette.accent),
))
.center_x(side)
.center_y(side)
.width(side)
.into(),
};
let label = shell
.render
.text(player.label())
@@ -120,7 +130,8 @@ pub(super) fn bar<'a>(
let preferred_label_width: f32 = if compact { 112. } else { 320. };
// Outer padding, the transport button, details padding, and cover spacing
// share the allocation with the label.
let chrome = 4. + 2. + f32::from(metrics.icon_size) + 12. + 8. + if compact { 0. } else { side + 8. };
let chrome =
4. + 2. + f32::from(metrics.icon_size) + 12. + 8. + if compact { 0. } else { side + 8. };
let label_width = width.map_or(preferred_label_width, |width| {
(width - chrome).max(0.).min(preferred_label_width)
});
@@ -150,7 +161,11 @@ pub(super) fn bar<'a>(
let playing = player.status == Playback::Playing;
let mut toggle = button::custom(bar_content(
accented_icon(
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
if playing {
icons::MEDIA_PAUSE
} else {
icons::MEDIA_PLAY
},
metrics.icon_size,
foreground,
color(palette.accent),
@@ -186,7 +201,9 @@ pub(super) fn bar<'a>(
let mut area = mouse_area(controls);
if player.can_raise {
area = area.on_middle_release(cosmic::Action::App(Message::MediaAction(shell.media.action("raise"))));
area = area.on_middle_release(cosmic::Action::App(Message::MediaAction(
shell.media.action("raise"),
)));
}
if player.can_control && player.volume.is_some() {
let action = shell.media.action("volume");
@@ -205,7 +222,11 @@ fn volume_delta(delta: mouse::ScrollDelta) -> f64 {
mouse::ScrollDelta::Lines { y, .. } => f64::from(y) * 0.05,
mouse::ScrollDelta::Pixels { y, .. } => f64::from(y) * 0.001,
};
if delta.is_finite() { delta.clamp(-1.0, 1.0) } else { 0.0 }
if delta.is_finite() {
delta.clamp(-1.0, 1.0)
} else {
0.0
}
}
fn transport<'a>(
@@ -228,7 +249,8 @@ fn transport_sized<'a>(
style: MenuStyle,
size: f32,
) -> Element<'a, cosmic::Action<Message>> {
let fill = (action == "play-pause").then_some(if enabled { style.primary } else { style.muted });
let fill =
(action == "play-pause").then_some(if enabled { style.primary } else { style.muted });
let foreground = match fill {
Some(fill) => cosmic::iced::Color {
a: fill.a,
@@ -247,7 +269,10 @@ fn transport_sized<'a>(
accented_icon(
source,
(size - 20.) as u16,
cosmic::iced::Color { a: 1., ..foreground },
cosmic::iced::Color {
a: 1.,
..foreground
},
color(style.theme.accent),
)
.opacity(foreground.a),
@@ -260,13 +285,19 @@ fn transport_sized<'a>(
.height(size)
.name(label);
if enabled {
control = control.on_press(cosmic::Action::App(Message::MediaAction(shell.media.action(action))));
control = control.on_press(cosmic::Action::App(Message::MediaAction(
shell.media.action(action),
)));
}
motion::circular_button(shell.render, control, foreground, fill, style.opacity, size)
}
pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
pub(super) fn hub<'a>(
shell: &'a FereseShell,
rows: MenuRows<'a>,
style: MenuStyle,
) -> MenuRows<'a> {
let Some(player) = &shell.media.snapshot.selected else {
return rows.push(
shell
@@ -276,31 +307,35 @@ pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuSty
.class(theme::Text::Color(style.muted)),
);
};
let cover: Element<'_, cosmic::Action<Message>> =
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
Some(handle) => image(handle.clone())
.width(64)
.height(64)
.opacity(style.opacity)
.content_fit(cosmic::iced::ContentFit::Cover)
.border_radius(10.)
.into(),
None => container(
accented_icon(
icons::MEDIA,
26,
color(style.theme.text_primary),
color(style.theme.accent),
)
.opacity(style.opacity),
)
.center_x(64)
.center_y(64)
.class(theme::Container::custom(move |_| {
ferese_theme::controls::surface_appearance(style.primary.scale_alpha(0.06), 10.)
}))
let cover: Element<'_, cosmic::Action<Message>> = match shell
.media
.artwork
.as_ref()
.and_then(|art| art.handle.as_ref())
{
Some(handle) => image(handle.clone())
.width(64)
.height(64)
.opacity(style.opacity)
.content_fit(cosmic::iced::ContentFit::Cover)
.border_radius(10.)
.into(),
};
None => container(
accented_icon(
icons::MEDIA,
26,
color(style.theme.text_primary),
color(style.theme.accent),
)
.opacity(style.opacity),
)
.center_x(64)
.center_y(64)
.class(theme::Container::custom(move |_| {
ferese_theme::controls::surface_appearance(style.primary.scale_alpha(0.06), 10.)
}))
.into(),
};
let mut info = column![
shell
.render
@@ -327,8 +362,9 @@ pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuSty
player.length_us.filter(|length| *length > 0),
player.position_at(shell.media.now_us),
) {
let value =
(shell.media.seeking.unwrap_or(position).min(length) as f64 / length as f64 * 10_000.).round() as u32;
let value = (shell.media.seeking.unwrap_or(position).min(length) as f64 / length as f64
* 10_000.)
.round() as u32;
if player.can_control && player.can_seek && player.track_id.is_some() {
info = info.push(
slider(0..=10_000_u32, value, move |value| {
@@ -358,7 +394,11 @@ pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuSty
),
transport_sized(
shell,
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
if playing {
icons::MEDIA_PAUSE
} else {
icons::MEDIA_PLAY
},
if playing { "Pause" } else { "Play" },
"play-pause",
player.can_toggle(),
@@ -381,22 +421,35 @@ pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuSty
)
}
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
pub(super) fn view<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
) -> MenuRows<'a> {
if let Some(player) = &shell.media.snapshot.selected {
let cover: Element<'_, cosmic::Action<Message>> =
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
Some(handle) => image(handle.clone())
.width(64)
.height(64)
.content_fit(cosmic::iced::ContentFit::Contain)
.border_radius(shell.render.radius(12.0))
.shape(BorderShape::Continuous)
.into(),
None => container(accented_icon(icons::MEDIA, 30, style.muted, color(style.theme.accent)))
.center_x(64)
.center_y(64)
.into(),
};
let cover: Element<'_, cosmic::Action<Message>> = match shell
.media
.artwork
.as_ref()
.and_then(|art| art.handle.as_ref())
{
Some(handle) => image(handle.clone())
.width(64)
.height(64)
.content_fit(cosmic::iced::ContentFit::Contain)
.border_radius(shell.render.radius(12.0))
.shape(BorderShape::Continuous)
.into(),
None => container(accented_icon(
icons::MEDIA,
30,
style.muted,
color(style.theme.accent),
))
.center_x(64)
.center_y(64)
.into(),
};
let text_size = if player.label().len() >= 24 { 13 } else { 15 };
let mut info = column![
shell
@@ -470,20 +523,23 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
} else {
progress = progress.push(seek_pill(
container(
cosmic::iced::widget::ProgressBar::new(0.0..=1.0, (position as f64 / length as f64) as f32)
.girth(4)
.length(Length::Fill)
.class(cosmic::theme::iced::ProgressBar::custom(move |_| {
cosmic::iced::widget::progress_bar::Style {
background: style.muted.scale_alpha(0.35).into(),
bar: color_with_opacity(style.theme.accent, style.opacity).into(),
border: cosmic::iced::Border {
shape: BorderShape::Circular,
radius: 2.0.into(),
..Default::default()
},
}
})),
cosmic::iced::widget::ProgressBar::new(
0.0..=1.0,
(position as f64 / length as f64) as f32,
)
.girth(4)
.length(Length::Fill)
.class(cosmic::theme::iced::ProgressBar::custom(move |_| {
cosmic::iced::widget::progress_bar::Style {
background: style.muted.scale_alpha(0.35).into(),
bar: color_with_opacity(style.theme.accent, style.opacity).into(),
border: cosmic::iced::Border {
shape: BorderShape::Circular,
radius: 2.0.into(),
..Default::default()
},
}
})),
)
.center_y(20),
style,
@@ -525,7 +581,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
),
transport(
shell,
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
if playing {
icons::MEDIA_PAUSE
} else {
icons::MEDIA_PLAY
},
if playing { "Pause" } else { "Play" },
"play-pause",
player.can_toggle(),
@@ -597,7 +657,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
shell.render,
label,
Message::MediaAction(pin),
if player.ignored { style.muted } else { style.primary },
if player.ignored {
style.muted
} else {
style.primary
},
style.opacity
),
super::controls::menu_button(
@@ -616,7 +680,13 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
}
if let Some(error) = &shell.media.error {
rows = rows.push(shell.render.text(error).size(12).class(theme::Text::Color(style.muted)));
rows = rows.push(
shell
.render
.text(error)
.size(12)
.class(theme::Text::Color(style.muted)),
);
}
rows
@@ -645,9 +715,24 @@ mod tests {
#[test]
fn wheel_changes_are_bounded_and_pixel_scrolling_is_not_a_full_step() {
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1.0 }), 0.05);
assert_eq!(volume_delta(mouse::ScrollDelta::Pixels { x: 0.0, y: 1.0 }), 0.001);
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: f32::NAN }), 0.0);
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1000.0 }), 1.0);
assert_eq!(
volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1.0 }),
0.05
);
assert_eq!(
volume_delta(mouse::ScrollDelta::Pixels { x: 0.0, y: 1.0 }),
0.001
);
assert_eq!(
volume_delta(mouse::ScrollDelta::Lines {
x: 0.0,
y: f32::NAN
}),
0.0
);
assert_eq!(
volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1000.0 }),
1.0
);
}
}
+77 -27
View File
@@ -19,9 +19,11 @@ use status::{Action, Snapshot};
use super::*;
type MenuHeading<'a> = cosmic::widget::Row<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
type MenuHeading<'a> =
cosmic::widget::Row<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
type MenuRows<'a> = cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
type MenuRows<'a> =
cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
#[derive(Clone, Copy)]
struct MenuStyle {
@@ -59,7 +61,10 @@ impl Menu {
) | (Self::Network, Action::Wifi(_))
| (Self::Bluetooth, Action::Bluetooth(_))
| (Self::Audio, Action::Volume(_) | Action::Mute(_))
| (Self::Battery, Action::Brightness(_) | Action::PowerProfile(_))
| (
Self::Battery,
Action::Brightness(_) | Action::PowerProfile(_)
)
| (Self::Notifications, Action::Dnd(_) | Action::Notifications)
);
relevant.then_some(error.message.as_str())
@@ -132,10 +137,9 @@ pub(crate) struct PopoverAnchor {
impl PopoverAnchor {
pub(crate) fn belongs_to(&self, panel: &crate::panel::Panel) -> bool {
self.panel == panel.id
&& self
.item
.as_ref()
.is_none_or(|id| id.0 == "_overflow" || panel.item(id).is_some_and(|item| item.visible))
&& self.item.as_ref().is_none_or(|id| {
id.0 == "_overflow" || panel.item(id).is_some_and(|item| item.visible)
})
}
pub fn same_item(&self, other: &Self) -> bool {
@@ -176,7 +180,11 @@ impl OpenMenu {
}
impl FereseShell {
pub(crate) fn view_hub_menu(&self, kind: Menu, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
pub(crate) fn view_hub_menu(
&self,
kind: Menu,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme;
let style = MenuStyle {
theme,
@@ -216,7 +224,12 @@ impl FereseShell {
Menu::Bluetooth => bluetooth::view(self, rows, style, true),
Menu::Audio => audio::view(self, rows, style, false),
Menu::Battery => battery::view(self, rows, style),
Menu::Calendar => rows.push(calendar::hub(self.render, self.calendar_offset, theme, opacity)),
Menu::Calendar => rows.push(calendar::hub(
self.render,
self.calendar_offset,
theme,
opacity,
)),
Menu::Media => media::hub(self, rows, style),
Menu::Notifications => {
let mut rows = notification_controls(self, rows, style, true);
@@ -305,8 +318,17 @@ impl FereseShell {
.class(theme::Text::Color(style.muted)),
);
}
if let Some(error) = self.status_error.as_ref().and_then(|error| kind.action_error(error)) {
rows = rows.push(self.render.text(error).size(12).class(theme::Text::Color(style.muted)));
if let Some(error) = self
.status_error
.as_ref()
.and_then(|error| kind.action_error(error))
{
rows = rows.push(
self.render
.text(error)
.size(12)
.class(theme::Text::Color(style.muted)),
);
}
container(rows)
.width(Length::Fill)
@@ -377,7 +399,12 @@ impl FereseShell {
.width(Length::Fixed(22.))
.into()
};
let badge = ferese_theme::menus::badge(heading_icon, color(theme.accent), p, self.render.radius(20.));
let badge = ferese_theme::menus::badge(
heading_icon,
color(theme.accent),
p,
self.render.radius(20.),
);
let mut heading = ferese_theme::menus::heading(badge, kind.title(), self.render.font);
let style = MenuStyle {
theme,
@@ -402,7 +429,9 @@ impl FereseShell {
Menu::Bluetooth => bluetooth::view(self, rows, style, false),
Menu::Audio => audio::view(self, rows, style, false),
Menu::Battery => battery::view(self, rows, style),
Menu::Calendar => rows.push(calendar::view(self.render, self.calendar_offset, theme, p)),
Menu::Calendar => {
rows.push(calendar::view(self.render, self.calendar_offset, theme, p))
}
Menu::Notifications => notification_controls(self, rows, style, false),
Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)),
Menu::Recording | Menu::Workspaces => rows,
@@ -423,25 +452,34 @@ impl FereseShell {
.as_ref()
.and_then(|error| menu.kind.action_error(error))
{
rows = rows.push(self.render.text(error).size(12).class(theme::Text::Color(Color {
a: p,
..Color::from_rgb8(230, 172, 90)
})));
rows = rows.push(
self.render
.text(error)
.size(12)
.class(theme::Text::Color(Color {
a: p,
..Color::from_rgb8(230, 172, 90)
})),
);
}
let compositor_material = menu.effects.is_some();
let body: Element<'_, cosmic::Action<Message>> = if matches!(kind, Menu::Overflow | Menu::Activities) {
cosmic::widget::scrollable(rows).height(Length::Shrink).into()
} else {
rows.into()
};
let body: Element<'_, cosmic::Action<Message>> =
if matches!(kind, Menu::Overflow | Menu::Activities) {
cosmic::widget::scrollable(rows)
.height(Length::Shrink)
.into()
} else {
rows.into()
};
let panel = container(body)
.id("ferese-blur-card")
.width(kind.width())
.padding(16)
.class(theme::Container::custom(move |_| container::Style {
background: (!compositor_material)
.then_some(Background::Color(color_with_opacity(theme.surface_popover, p))),
background: (!compositor_material).then_some(Background::Color(
color_with_opacity(theme.surface_popover, p),
)),
text_color: Some(primary),
icon_color: Some(primary),
border: Border {
@@ -488,7 +526,14 @@ fn notification_controls<'a>(
opacity: p,
} = style;
if let Some(n) = &shell.status.notifications {
let toggle = toggle_row(shell.render, "Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p);
let toggle = toggle_row(
shell.render,
"Do Not Disturb",
n.dnd,
Action::Dnd(!n.dnd),
theme,
p,
);
rows = rows.push(if combined {
control_card(shell.render, toggle, primary, p)
} else {
@@ -528,7 +573,9 @@ mod tests {
#[test]
fn activity_popups_scroll_to_the_last_row_in_regular_panels() {
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, renderer::Headless, widget};
use cosmic::iced::advanced::{
Layout, Shell, clipboard, layout, renderer::Headless, widget,
};
use cosmic::iced::{Event, Rectangle, Size, Vector, mouse};
use ferese_activities::{Data, Owner, Source, Store, Template, Value};
#[derive(Default)]
@@ -575,7 +622,10 @@ mod tests {
for n in 0..24 {
store
.start(
Owner { pid: n + 1, birth: 1 },
Owner {
pid: n + 1,
birth: 1,
},
n.to_string(),
Data {
source: Source::Publisher,
+16 -4
View File
@@ -7,9 +7,15 @@ use super::{Menu, MenuRows, MenuStyle};
use crate::FereseShell;
use crate::status::Action;
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
pub(super) fn view<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
) -> MenuRows<'a> {
let MenuStyle {
primary, opacity: p, ..
primary,
opacity: p,
..
} = style;
if let Some(n) = &shell.status.network {
let connected = n.enabled && n.connection.is_some();
@@ -42,7 +48,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
rows = rows.push(
column![
row![
shell.render.text("Signal strength").size(12).width(Length::Fill),
shell
.render
.text("Signal strength")
.size(12)
.width(Length::Fill),
shell.render.text(strength).size(12)
],
level_meter(shell.render, n.signal, primary, p),
@@ -59,7 +69,9 @@ pub(super) fn heading<'a>(
mut heading: super::MenuHeading<'a>,
style: MenuStyle,
) -> super::MenuHeading<'a> {
let MenuStyle { theme, opacity: p, .. } = style;
let MenuStyle {
theme, opacity: p, ..
} = style;
if let Some(network) = &shell.status.network {
heading = heading.push(shell_switch(
shell.render,
@@ -2,7 +2,11 @@ use cosmic::widget::{column, slider};
use super::*;
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
pub(super) fn view<'a>(
shell: &'a FereseShell,
mut rows: MenuRows<'a>,
style: MenuStyle,
) -> MenuRows<'a> {
let Some(status) = &shell.status.night_light else {
return rows.push(shell.render.text("Night light is unavailable").size(13));
};
@@ -28,9 +32,19 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
shell.render.text("Lower temperatures are warmer").size(12),
]
.spacing(8);
rows = rows.push(control_card(shell.render, warmth.into(), style.primary, style.opacity));
rows = rows.push(control_card(
shell.render,
warmth.into(),
style.primary,
style.opacity,
));
if status.supported_outputs == 0 {
rows = rows.push(shell.render.text("No display supports night light").size(13));
rows = rows.push(
shell
.render
.text("No display supports night light")
.size(13),
);
} else if status.enabled && status.failed_outputs > 0 {
rows = rows.push(
shell
+35 -8
View File
@@ -22,7 +22,11 @@ pub(super) fn view<'a>(
} = style;
use ferese_config::theme::{Appearance, Mode};
let mut modes = row([]).spacing(6);
for (mode, label) in [(Mode::Light, "☀ Light"), (Mode::Dark, "☾ Dark"), (Mode::Auto, "◐ Auto")] {
for (mode, label) in [
(Mode::Light, "☀ Light"),
(Mode::Dark, "☾ Dark"),
(Mode::Auto, "◐ Auto"),
] {
modes = modes.push(motion::button(
shell.render,
button::custom(shell.render.text(label).font(shell.render.font).size(13))
@@ -45,7 +49,12 @@ pub(super) fn view<'a>(
let settings = motion::button(
shell.render,
button::custom(
ferese_theme::icons::tinted(ferese_theme::icons::SETTINGS, 18, color(theme.text_primary)).opacity(p),
ferese_theme::icons::tinted(
ferese_theme::icons::SETTINGS,
18,
color(theme.text_primary),
)
.opacity(p),
)
.name("Open Ferese Settings")
.width(30)
@@ -135,7 +144,10 @@ pub(super) fn view<'a>(
);
}
if shell.status.network.is_some() || shell.status.bluetooth.is_some() || shell.status.battery.is_some() {
if shell.status.network.is_some()
|| shell.status.bluetooth.is_some()
|| shell.status.battery.is_some()
{
rows = rows.push(control_card(shell.render, connections.into(), primary, p));
}
rows = audio::view(shell, rows, style, true);
@@ -195,7 +207,10 @@ fn connection_control<'a>(
.height(28)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center);
let content = column![icon].spacing(2).width(Length::Fill).align_x(Alignment::Center);
let content = column![icon]
.spacing(2)
.width(Length::Fill)
.align_x(Alignment::Center);
let content = content.push(
render
.text(connection_caption(detail))
@@ -210,12 +225,18 @@ fn connection_control<'a>(
.class(theme::Text::Color(muted)),
);
let Some(action) = action else {
return container(content).width(Length::FillPortion(1)).padding(4).into();
return container(content)
.width(Length::FillPortion(1))
.padding(4)
.into();
};
let button = button::custom(content)
.width(Length::Fill)
.padding(4)
.name(format!("{label}: {}, {detail}", if enabled { "on" } else { "off" }))
.name(format!(
"{label}: {}, {detail}",
if enabled { "on" } else { "off" }
))
.on_press(cosmic::Action::App(Message::Control(action)));
let tile: Element<'_, cosmic::Action<Message>> = if selected {
button
@@ -277,8 +298,14 @@ mod tests {
assert_eq!(super::connection_caption("88%"), "88%");
assert_eq!(super::connection_caption("Flow84@Lofree"), "Flow84@Lofr…");
assert_eq!(super::connection_caption("123456789012"), "123456789012");
assert_eq!(super::connection_caption("My very long headphones"), "My very lon…");
assert_eq!(super::connection_caption(" Device\n name "), "Device name");
assert_eq!(
super::connection_caption("My very long headphones"),
"My very lon…"
);
assert_eq!(
super::connection_caption(" Device\n name "),
"Device name"
);
let caption = super::connection_caption("耳機耳機耳機耳機耳機耳機耳機");
assert_eq!(caption.chars().count(), 12);
assert!(caption.ends_with('…'));
+169 -68
View File
@@ -1,16 +1,21 @@
use super::{
Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING, Element, Event,
EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin,
IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific,
Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop,
destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, theme,
wayland, window, wl_output, wl_registry, wl_surface,
Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING,
Element, Event, EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1,
GlobalListContents, IcedMargin, IcedOutput, KeyboardInteractivity, Layer, Length, Limits,
Message, Ordering, OutputSurfaces, PlatformSpecific, Proxy, QueueHandle,
SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop,
destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden,
registry_queue_init, status_ui, theme, wayland, window, wl_output, wl_registry, wl_surface,
};
#[cfg(test)]
mod tests;
pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
pub(super) fn panel_placement(
edge: ferese_panel::Edge,
margin: i32,
side: i32,
) -> (Anchor, IcedMargin) {
let (anchor, top, bottom) = match edge {
ferese_panel::Edge::Top => (Anchor::TOP, margin, 0),
ferese_panel::Edge::Bottom => (Anchor::BOTTOM, 0, margin),
@@ -27,12 +32,20 @@ pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32)
}
impl FereseShell {
pub(super) fn output_event(&mut self, event: wayland::OutputEvent, output: wl_output::WlOutput) -> Task<Message> {
pub(super) fn output_event(
&mut self,
event: wayland::OutputEvent,
output: wl_output::WlOutput,
) -> Task<Message> {
if matches!(event, wayland::OutputEvent::Removed) {
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
let entry = self.outputs.remove(index);
self.refresh_media_art(false);
let menu = if self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == entry.bar) {
let menu = if self
.menu
.as_ref()
.is_some_and(|menu| menu.anchor.parent == entry.bar)
{
self.destroy_menu()
} else {
Task::none()
@@ -84,7 +97,12 @@ impl FereseShell {
let bar = entry.bar;
let margin = self.update_panel_margin(bar, false);
let popup = if resized && self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == bar) {
let popup = if resized
&& self
.menu
.as_ref()
.is_some_and(|menu| menu.anchor.parent == bar)
{
self.destroy_menu()
} else {
Task::none()
@@ -105,13 +123,18 @@ impl FereseShell {
margin,
popup,
tooltip,
window::scale_factor(bar).map(move |scale| cosmic::Action::App(Message::PanelScale(bar, scale))),
window::scale_factor(bar)
.map(move |scale| cosmic::Action::App(Message::PanelScale(bar, scale))),
]);
}
let bar_surface_id = window::Id::unique();
let wallpaper_surface_id = window::Id::unique();
let runtime = super::panel_runtime::Runtime::new(&self.config.panels[0], &self.snapshot, name.as_deref());
let runtime = super::panel_runtime::Runtime::new(
&self.config.panels[0],
&self.snapshot,
name.as_deref(),
);
let panel = runtime.panel(&self.config.panels[0]);
let geometry = panel.resolved_geometry();
let panel_surface = panel.surface();
@@ -123,7 +146,8 @@ impl FereseShell {
.find(|output| Some(output.name.as_str()) == name.as_deref())
.map(|output| output.id),
));
let (edge_anchor, margin) = panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
let (edge_anchor, margin) =
panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
let bar = BarMetrics::from(geometry);
let hub_size = runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
let bar_anchor = if hub_size.is_some() {
@@ -189,9 +213,11 @@ impl FereseShell {
namespace: "ferese-shell-top-bar".to_owned(),
// Measure at full width before applying the requested side inset.
margin,
size: Some(hub_size.map_or((None, Some(bar.height.round() as u32)), |size| {
(Some(size[0]), Some(size[1]))
})),
size: Some(
hub_size.map_or((None, Some(bar.height.round() as u32)), |size| {
(Some(size[0]), Some(size[1]))
}),
),
size_limits: Limits::NONE,
// Reserve space toward the desktop; layer-shell accounts for the edge margin.
exclusive_zone,
@@ -221,9 +247,15 @@ impl FereseShell {
self.outputs
.iter()
.any(|entry| entry.bar == id || entry.wallpaper == Some(id))
|| self.launcher.as_ref().is_some_and(|launcher| launcher.id == id)
|| self
.launcher
.as_ref()
.is_some_and(|launcher| launcher.id == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|| self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|| self
.system_modal
.as_ref()
.is_some_and(|modal| modal.contains(id))
|| self
.notification_surface
.as_ref()
@@ -235,11 +267,17 @@ impl FereseShell {
if let Some(task) = self.launcher_event(&event, id) {
return task;
}
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) {
if matches!(
event,
Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)
) {
return self.hub_hover(id, true);
}
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorLeft)) {
return Task::batch([self.dismiss_workspace_tooltip_for_bar(id), self.hub_hover(id, false)]);
return Task::batch([
self.dismiss_workspace_tooltip_for_bar(id),
self.hub_hover(id, false),
]);
}
if let Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) = &event
&& let Some(modal) = &mut self.system_modal
@@ -326,9 +364,15 @@ impl FereseShell {
let target = Id::new("ferese-panel-hub");
let focus: Box<dyn cosmic::iced::advanced::widget::Operation<cosmic::Action<Message>>> =
if modifiers.shift() {
Box::new(operation::scoped(target, operation::focusable::focus_previous()))
Box::new(operation::scoped(
target,
operation::focusable::focus_previous(),
))
} else {
Box::new(operation::scoped(target, operation::focusable::focus_next()))
Box::new(operation::scoped(
target,
operation::focusable::focus_next(),
))
};
let task = cosmic::iced::advanced::widget::operate(focus);
return if self.hub_mode(id)
@@ -348,7 +392,10 @@ impl FereseShell {
..
}) = &event
&& !modifiers.control()
&& self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
&& self
.system_modal
.as_ref()
.is_some_and(|modal| modal.contains(id))
{
return self.navigate_system_modal(modifiers.shift());
}
@@ -380,9 +427,10 @@ impl FereseShell {
return self.close_hub(*layer);
}
match event {
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output))) => {
self.output_event(event, output)
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
event,
output,
))) => self.output_event(event, output),
Event::Window(window::Event::Rescaled(scale)) => {
cosmic::task::message(cosmic::Action::App(Message::PanelScale(id, scale)))
}
@@ -392,34 +440,47 @@ impl FereseShell {
// owner has gone away. IDs are never reused by the shell.
Task::batch([
destroy_layer_surface(id),
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(id))),
cosmic::task::message(cosmic::Action::Surface(
cosmic::surface::action::destroy_popup(id),
)),
])
}
Event::Window(window::Event::Opened { .. })
if self.outputs.iter().any(|entry| entry.bar == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|| self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|| self
.system_modal
.as_ref()
.is_some_and(|modal| modal.contains(id))
|| self
.notification_surface
.as_ref()
.is_some_and(|surface| surface.id == id) =>
{
Task::batch([
window::scale_factor(id).map(move |scale| cosmic::Action::App(Message::PanelScale(id, scale))),
window::scale_factor(id)
.map(move |scale| cosmic::Action::App(Message::PanelScale(id, scale))),
self.focus_system_modal(id),
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))),
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.close_menu(),
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(event, surface, popup_id))) => {
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
event,
surface,
popup_id,
))) => {
if matches!(event, wayland::PopupEvent::Done) {
self.closing_menu_effects.remove(&popup_id);
}
if self.workspace_ui.tooltip == Some(popup_id) && matches!(event, wayland::PopupEvent::Done) {
if self.workspace_ui.tooltip == Some(popup_id)
&& matches!(event, wayland::PopupEvent::Done)
{
self.workspace_ui.tooltip = None;
return self.dismiss_workspace_tooltip();
}
@@ -444,7 +505,9 @@ impl FereseShell {
}
Task::none()
}
Event::Window(window::Event::Closed) if self.menu.as_ref().is_some_and(|menu| menu.id == id) => {
Event::Window(window::Event::Closed)
if self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
{
self.menu = None;
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
Task::none()
@@ -454,7 +517,10 @@ impl FereseShell {
self.dismiss_workspace_tooltip()
}
Event::Window(window::Event::Closed)
if self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) =>
if self
.system_modal
.as_ref()
.is_some_and(|modal| modal.contains(id)) =>
{
self.close_system_modal()
}
@@ -462,9 +528,11 @@ impl FereseShell {
self.closing_menu_effects.remove(&id);
Task::none()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id)))
if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) =>
{
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
_,
surface,
frame_id,
))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => {
if let Some(menu) = &mut self.menu
&& menu.effects.is_none()
{
@@ -472,22 +540,32 @@ impl FereseShell {
}
Task::none()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id)))
if self.outputs.iter().any(|entry| entry.bar == frame_id) =>
{
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
_,
surface,
frame_id,
))) if self.outputs.iter().any(|entry| entry.bar == frame_id) => {
self.attach_effects(frame_id, &surface);
Task::none()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id)))
if self
.notification_surface
.as_ref()
.is_some_and(|entry| entry.id == layer_id) =>
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
_,
surface,
layer_id,
))) if self
.notification_surface
.as_ref()
.is_some_and(|entry| entry.id == layer_id) =>
{
let entry = self.notification_surface.as_mut().unwrap();
if entry.effects.is_none() {
match EffectsBinding::attach_role(&mut self.effects_context, &surface, None, 1.0) {
match EffectsBinding::attach_role(
&mut self.effects_context,
&surface,
None,
1.0,
) {
Ok(binding) => entry.effects = Some(binding),
Err(error) => {
eprintln!("ferese-shell: notification effects unavailable: {error}")
@@ -496,9 +574,11 @@ impl FereseShell {
}
Task::none()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id)))
if self.outputs.iter().any(|entry| entry.bar == layer_id) =>
{
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
_,
surface,
layer_id,
))) if self.outputs.iter().any(|entry| entry.bar == layer_id) => {
self.attach_effects(layer_id, &surface);
self.attach_power_material(layer_id, &surface);
window::scale_factor(layer_id)
@@ -523,8 +603,12 @@ impl FereseShell {
// The first panel presentation supplies the role and measured shape
// together with its buffer. Enabling the role here would paint the
// whole surface before those bounds arrive.
let binding =
EffectsBinding::attach_role(&mut self.effects_context, surface, None, if hidden { 0.0 } else { 1.0 });
let binding = EffectsBinding::attach_role(
&mut self.effects_context,
surface,
None,
if hidden { 0.0 } else { 1.0 },
);
match binding {
Ok(binding) => entry.effects = Some(binding),
@@ -566,21 +650,28 @@ pub(super) fn native_wayland_surface(
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
let display = window.display_handle().map_err(|error| error.to_string())?;
let handle = window.window_handle().map_err(|error| error.to_string())?;
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) = (display.as_raw(), handle.as_raw())
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) =
(display.as_raw(), handle.as_raw())
else {
return Err("shell material requires a Wayland surface".to_owned());
};
// SAFETY: iced lends matching, live Wayland handles for this callback.
// This backend borrows the display; it never disconnects iced's connection.
// Both handles belong to the runtime, which outlives the shell bindings.
let backend = unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) };
let backend = unsafe {
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
};
let connection = Connection::from_backend(backend);
// SAFETY: raw-window-handle identifies this pointer as a live wl_surface.
let id = unsafe {
wayland_client::backend::ObjectId::from_ptr(wl_surface::WlSurface::interface(), handle.surface.as_ptr().cast())
wayland_client::backend::ObjectId::from_ptr(
wl_surface::WlSurface::interface(),
handle.surface.as_ptr().cast(),
)
}
.map_err(|error| error.to_string())?;
let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let surface =
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
Ok((connection, surface))
}
@@ -611,7 +702,12 @@ fn validate_region_counts(regions: usize, opacities: usize) -> Result<(), std::i
Ok(())
}
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
type EffectPresentation = (
Vec<[f32; 5]>,
u32,
Vec<u32>,
ferese_surface_effects_v1::Role,
);
// Keep one registry on the shell connection: wl_registry has no destructor on
// the protocol versions we support, so creating one per popup leaks objects.
@@ -655,10 +751,9 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend);
// Native handles can create distinct borrowed Backend wrappers around
// the same wl_display. Backend equality compares wrappers, not displays.
if context
.as_ref()
.is_none_or(|context| context.connection.backend().display_ptr() != connection.backend().display_ptr())
{
if context.as_ref().is_none_or(|context| {
context.connection.backend().display_ptr() != connection.backend().display_ptr()
}) {
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
// Cache the registry even when this compositor lacks our extension.
let manager = globals
@@ -742,17 +837,19 @@ impl EffectsBinding {
.collect();
validate_region_counts(regions.len(), values.len())?;
let mut previous = self.presentation.borrow_mut();
if previous
.as_ref()
.is_some_and(|old| old.0 == regions && old.1 == opacity && old.2 == values && old.3 == role)
{
if previous.as_ref().is_some_and(|old| {
old.0 == regions && old.1 == opacity && old.2 == values && old.3 == role
}) {
return Ok(());
}
self.surface.set_presentation(
role,
encode_regions(regions),
opacity,
values.iter().flat_map(|value| value.to_ne_bytes()).collect(),
values
.iter()
.flat_map(|value| value.to_ne_bytes())
.collect(),
);
self.connection.flush()?;
if let Some(old) = previous.as_mut() {
@@ -768,7 +865,10 @@ impl EffectsBinding {
Ok(())
}
pub(super) fn set_regions(&self, regions: &[[f32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
pub(super) fn set_regions(
&self,
regions: &[[f32; 5]],
) -> Result<(), Box<dyn std::error::Error>> {
self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover)
}
@@ -795,7 +895,8 @@ impl EffectsBinding {
pub(super) 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);
self.surface
.set_role(ferese_surface_effects_v1::Role::Panel);
} else {
self.surface.clear_role();
}
+14 -5
View File
@@ -147,8 +147,11 @@ fn effects_lifecycle(advertise: bool) {
}
});
let connection = wayland_client::Connection::from_socket(client).unwrap();
let (globals, queue) = wayland_client::globals::registry_queue_init::<super::EffectsState>(&connection).unwrap();
let compositor = globals.bind::<WlCompositor, _, _>(&queue.handle(), 1..=1, ()).unwrap();
let (globals, queue) =
wayland_client::globals::registry_queue_init::<super::EffectsState>(&connection).unwrap();
let compositor = globals
.bind::<WlCompositor, _, _>(&queue.handle(), 1..=1, ())
.unwrap();
let mut context = None;
let mut first = None;
for _ in 0..20 {
@@ -157,7 +160,9 @@ fn effects_lifecycle(advertise: bool) {
// Match window::run: each native callback lends the same display through
// a fresh borrowed backend. Neither wrapper owns the actual display.
let borrowed = wayland_client::Connection::from_backend(unsafe {
wayland_client::backend::Backend::from_foreign_display(connection.backend().display_ptr())
wayland_client::backend::Backend::from_foreign_display(
connection.backend().display_ptr(),
)
});
assert_ne!(borrowed, connection);
let id = unsafe {
@@ -167,7 +172,8 @@ fn effects_lifecycle(advertise: bool) {
)
}
.unwrap();
let borrowed_surface = wayland_client::protocol::wl_surface::WlSurface::from_id(&borrowed, id).unwrap();
let borrowed_surface =
wayland_client::protocol::wl_surface::WlSurface::from_id(&borrowed, id).unwrap();
let binding = super::EffectsBinding::attach_role(&mut context, &borrowed_surface, None, 1.);
assert_eq!(binding.is_ok(), advertise);
if let Some(first) = &first {
@@ -190,7 +196,10 @@ fn effects_lifecycle(advertise: bool) {
drop(first);
drop(context);
connection.roundtrip().unwrap();
assert_eq!(counts.lock().unwrap().manager_destroys, usize::from(advertise));
assert_eq!(
counts.lock().unwrap().manager_destroys,
usize::from(advertise)
);
drop(stop);
worker.join().unwrap();
}
+147 -56
View File
@@ -88,7 +88,11 @@ impl SystemModal {
}
impl FereseShell {
pub(super) fn open_system_modal(&mut self, action: PowerAction, output_name: Option<&str>) -> Task<Message> {
pub(super) fn open_system_modal(
&mut self,
action: PowerAction,
output_name: Option<&str>,
) -> Task<Message> {
self.open_modal(Content::Power(action), output_name)
}
@@ -107,14 +111,18 @@ impl FereseShell {
}
pub(super) fn rebuild_system_modal(&mut self) -> Task<Message> {
let guide = self.system_modal.as_ref().and_then(|modal| match &modal.content {
Content::Guide(entries)
if !modal.motion.closing() && (self.guide_load.manual || self.config.status.keybinding_guide) =>
{
Some(entries.clone())
}
_ => None,
});
let guide = self
.system_modal
.as_ref()
.and_then(|modal| match &modal.content {
Content::Guide(entries)
if !modal.motion.closing()
&& (self.guide_load.manual || self.config.status.keybinding_guide) =>
{
Some(entries.clone())
}
_ => None,
});
let destroy = self.destroy_system_modal(true);
if self.display_mode.open {
let open = self.open_modal(Content::Displays, None);
@@ -171,9 +179,11 @@ impl FereseShell {
.position(|output| output.name.as_deref() == Some(name))
})
.or_else(|| {
self.menu
.as_ref()
.and_then(|menu| self.outputs.iter().position(|output| output.bar == menu.anchor.parent))
self.menu.as_ref().and_then(|menu| {
self.outputs
.iter()
.position(|output| output.bar == menu.anchor.parent)
})
})
.or_else(|| {
self.active_bar()
@@ -283,7 +293,11 @@ impl FereseShell {
)
}
pub(super) fn attach_power_material(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
pub(super) fn attach_power_material(
&mut self,
id: window::Id,
surface: &wl_surface::WlSurface,
) {
let Some(modal) = &mut self.system_modal else {
return;
};
@@ -302,7 +316,11 @@ impl FereseShell {
}
pub(super) fn close_system_modal(&mut self) -> Task<Message> {
if self.system_modal.as_ref().is_some_and(SystemModal::is_display_mode) {
if self
.system_modal
.as_ref()
.is_some_and(SystemModal::is_display_mode)
{
if self.display_mode.busy {
return Task::none();
}
@@ -378,7 +396,10 @@ impl FereseShell {
if self.system_modal.as_ref().is_some_and(|modal| {
!modal.is_display_mode()
&& !modal.motion.closing()
&& modal.surfaces.iter().any(|surface| surface.id == id && surface.primary)
&& modal
.surfaces
.iter()
.any(|surface| surface.id == id && surface.primary)
}) {
button::focus("ferese-modal-cancel".into())
} else {
@@ -414,7 +435,12 @@ impl FereseShell {
let action = match power {
PowerAction::Logout(serial) => {
if let Some(control) = &self.control {
control.confirm_logout(serial, approval.revision, approval.token, approval.force());
control.confirm_logout(
serial,
approval.revision,
approval.token,
approval.force(),
);
return self.destroy_system_modal(false);
}
modal.error = Some("Ferese shell control is unavailable".to_owned());
@@ -447,7 +473,11 @@ impl FereseShell {
Task::batch([destroy, execute])
}
pub(super) fn finish_power_action(&mut self, id: window::Id, result: Result<(), String>) -> Task<Message> {
pub(super) fn finish_power_action(
&mut self,
id: window::Id,
result: Result<(), String>,
) -> Task<Message> {
let Some((pending_id, action)) = self.pending_power else {
return Task::none();
};
@@ -495,7 +525,11 @@ impl FereseShell {
)
}
fn view_system_modal_at(&self, id: window::Id, now: Instant) -> Element<'_, cosmic::Action<Message>> {
fn view_system_modal_at(
&self,
id: window::Id,
now: Instant,
) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else {
return self.render.text("").into();
};
@@ -531,10 +565,16 @@ impl FereseShell {
column::with_capacity(inhibitors.reasons.len()).spacing(6),
|rows, reason| rows.push(self.render.text(reason).size(13)),
);
rows = rows
.push(container(cosmic::widget::scrollable(items).height(Length::Shrink)).max_height(160));
rows = rows.push(
container(cosmic::widget::scrollable(items).height(Length::Shrink))
.max_height(160),
);
}
let label = if modal.inhibitors.as_ref().is_some_and(|approval| approval.force()) {
let label = if modal
.inhibitors
.as_ref()
.is_some_and(|approval| approval.force())
{
format!("{} anyway", action.label())
} else {
action.label().to_owned()
@@ -546,40 +586,55 @@ impl FereseShell {
container(
row![
Space::new().width(Length::Fill),
ferese_theme::controls::text_button("Cancel", self.render.font, palette, false)
.class(ferese_theme::controls::material_button_style_with_focus(
palette,
false,
theme.material_opacity(),
modal.focus_visible,
))
.id("ferese-modal-cancel".into())
.on_press(cosmic::Action::App(Message::CancelPower)),
ferese_theme::controls::text_button(label, self.render.font, palette, true)
.class(ferese_theme::controls::button_style_with_focus(
palette,
true,
modal.focus_visible,
))
.on_press_maybe(
modal
.inhibitors
.is_some()
.then_some(cosmic::Action::App(Message::ExecutePower))
),
ferese_theme::controls::text_button(
"Cancel",
self.render.font,
palette,
false
)
.class(ferese_theme::controls::material_button_style_with_focus(
palette,
false,
theme.material_opacity(),
modal.focus_visible,
))
.id("ferese-modal-cancel".into())
.on_press(cosmic::Action::App(Message::CancelPower)),
ferese_theme::controls::text_button(
label,
self.render.font,
palette,
true
)
.class(ferese_theme::controls::button_style_with_focus(
palette,
true,
modal.focus_visible,
))
.on_press_maybe(
modal
.inhibitors
.is_some()
.then_some(cosmic::Action::App(Message::ExecutePower))
),
]
.spacing(10),
)
.id("ferese-system-modal-controls"),
)
}
Content::Displays => column![container(self.display_mode_rows()).id("ferese-system-modal-controls")],
Content::Displays => {
column![container(self.display_mode_rows()).id("ferese-system-modal-controls")]
}
Content::Guide(entries) => {
let mut bindings = column::with_capacity(entries.len()).spacing(12);
for entry in entries {
bindings = bindings.push(
row![
self.render.text(&entry.keys).size(13).width(Length::FillPortion(1)),
self.render
.text(&entry.keys)
.size(13)
.width(Length::FillPortion(1)),
self.render
.text(&entry.description)
.size(13)
@@ -634,8 +689,10 @@ impl FereseShell {
})
.padding(if modal.is_guide() { 24 } else { 18 })
.class(theme::Container::custom(move |_| container::Style {
background: (!material)
.then_some(Background::Color(color_with_opacity(theme.surface_popover, 1.0))),
background: (!material).then_some(Background::Color(color_with_opacity(
theme.surface_popover,
1.0,
))),
text_color: Some(palette.text),
icon_color: Some(palette.text),
border: Border {
@@ -662,7 +719,11 @@ impl FereseShell {
} else {
content
};
let scrim_alpha = if surface.effects.is_some() { 1.0 } else { progress };
let scrim_alpha = if surface.effects.is_some() {
1.0
} else {
progress
};
let centered = container(content)
.width(Length::Fill)
.height(Length::Fill)
@@ -670,7 +731,12 @@ impl FereseShell {
.align_x(cosmic::iced::Alignment::Center)
.align_y(cosmic::iced::Alignment::Center)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.34 * scrim_alpha))),
background: Some(Background::Color(Color::from_rgba(
0.0,
0.0,
0.0,
0.34 * scrim_alpha,
))),
..Default::default()
}));
centered.into()
@@ -678,13 +744,21 @@ impl FereseShell {
}
// Restrict Tab traversal to the modal actions, excluding the panel.
fn modal_focus_operation<T: Send + 'static>(backwards: bool) -> Box<dyn cosmic::iced::advanced::widget::Operation<T>> {
fn modal_focus_operation<T: Send + 'static>(
backwards: bool,
) -> Box<dyn cosmic::iced::advanced::widget::Operation<T>> {
use cosmic::iced::advanced::widget::{Id, operation};
let target = Id::new("ferese-system-modal-controls");
if backwards {
Box::new(operation::scoped(target, operation::focusable::focus_previous()))
Box::new(operation::scoped(
target,
operation::focusable::focus_previous(),
))
} else {
Box::new(operation::scoped(target, operation::focusable::focus_next()))
Box::new(operation::scoped(
target,
operation::focusable::focus_next(),
))
}
}
@@ -755,17 +829,31 @@ mod tests {
let mut current: Box<dyn Operation<()>> = modal_focus_operation(backwards);
loop {
current.focusable(Some(&Id::new("bar")), Rectangle::default(), &mut outside);
current.container(Some(&Id::new("ferese-system-modal-controls")), Rectangle::default());
current.container(
Some(&Id::new("ferese-system-modal-controls")),
Rectangle::default(),
);
current.traverse(&mut |operation| {
operation.focusable(Some(&Id::new("cancel")), Rectangle::default(), &mut cancel);
operation.focusable(Some(&Id::new("confirm")), Rectangle::default(), &mut confirm);
operation.focusable(
Some(&Id::new("cancel")),
Rectangle::default(),
&mut cancel,
);
operation.focusable(
Some(&Id::new("confirm")),
Rectangle::default(),
&mut confirm,
);
});
match current.finish() {
operation::Outcome::Chain(next) => current = next,
_ => break,
}
}
assert!(outside.0, "modal navigation changed focus outside the modal");
assert!(
outside.0,
"modal navigation changed focus outside the modal"
);
assert_ne!(cancel.0, confirm.0);
assert_eq!(cancel.0, expected_cancel);
}
@@ -787,7 +875,10 @@ mod tests {
PowerAction::from_status(status::Action::Suspend),
Some(PowerAction::Suspend)
);
assert_eq!(PowerAction::from_status(status::Action::Brightness(50)), None);
assert_eq!(
PowerAction::from_status(status::Action::Brightness(50)),
None
);
assert_eq!(PowerAction::Logout(1).label(), "Log out");
}
}
+130 -49
View File
@@ -30,7 +30,8 @@ impl FereseShell {
}
)
});
self.workspace_ui.update(&self.snapshot, self.config.animations);
self.workspace_ui
.update(&self.snapshot, self.config.animations);
let icons = self.workspace_ui.load_icons(&self.snapshot, needs_icons);
let tooltip = if self.workspace_ui.hovered.as_ref().is_some_and(|hover| {
!self
@@ -46,7 +47,10 @@ impl FereseShell {
Task::batch([icons, tooltip, self.sync_hub_workspaces()])
}
pub(super) fn workspace_icons_loaded(&mut self, result: Result<IconIndex, String>) -> Task<Message> {
pub(super) fn workspace_icons_loaded(
&mut self,
result: Result<IconIndex, String>,
) -> Task<Message> {
self.workspace_ui.icons_loading = false;
match result {
Ok(index) => {
@@ -93,7 +97,9 @@ fn discover_icons(entries: Option<Arc<Vec<fde::DesktopEntry>>>, app_ids: Vec<Str
icon::from_name(source)
.prefer_svg(true)
.size(32)
.fallback(Some(icon::IconFallback::Names(vec!["application-x-executable".into()])))
.fallback(Some(icon::IconFallback::Names(vec![
"application-x-executable".into(),
])))
.handle()
};
(app_id, handle)
@@ -162,7 +168,12 @@ impl Operation<Option<Rectangle>> for FindBounds {
impl FereseShell {
pub(super) fn dismiss_workspace_tooltip_for_bar(&mut self, bar: window::Id) -> Task<Message> {
if self.workspace_ui.hovered.as_ref().is_some_and(|hover| hover.bar == bar) {
if self
.workspace_ui
.hovered
.as_ref()
.is_some_and(|hover| hover.bar == bar)
{
self.dismiss_workspace_tooltip()
} else {
Task::none()
@@ -172,9 +183,14 @@ impl FereseShell {
pub(super) fn dismiss_workspace_tooltip(&mut self) -> Task<Message> {
self.workspace_ui.serial = self.workspace_ui.serial.wrapping_add(1);
self.workspace_ui.hovered = None;
self.workspace_ui.tooltip.take().map_or_else(Task::none, |id| {
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(id)))
})
self.workspace_ui
.tooltip
.take()
.map_or_else(Task::none, |id| {
cosmic::task::message(cosmic::Action::Surface(
cosmic::surface::action::destroy_popup(id),
))
})
}
pub(super) fn hover_workspace(
@@ -229,8 +245,14 @@ impl FereseShell {
.map(move |bounds| cosmic::Action::App(Message::WorkspaceTooltipBounds(serial, bounds)))
}
pub(super) fn show_workspace_tooltip(&mut self, serial: u64, bounds: Option<Rectangle>) -> Task<Message> {
use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner};
pub(super) fn show_workspace_tooltip(
&mut self,
serial: u64,
bounds: Option<Rectangle>,
) -> Task<Message> {
use cosmic::iced::platform_specific::runtime::wayland::popup::{
SctkPopupSettings, SctkPositioner,
};
let Some(hover) = self
.workspace_ui
.hovered
@@ -245,7 +267,11 @@ impl FereseShell {
if self.workspace_ui.tooltip.is_some() {
return Task::none();
}
if self.bar_hidden(hover.bar) || self.menu.is_some() || self.system_modal.is_some() || self.overview_active {
if self.bar_hidden(hover.bar)
|| self.menu.is_some()
|| self.system_modal.is_some()
|| self.overview_active
{
return self.dismiss_workspace_tooltip();
}
if !self
@@ -322,7 +348,9 @@ impl FereseShell {
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
))
.class(cosmic::theme::Text::Color(crate::color(self.config.theme.text_primary))),
.class(cosmic::theme::Text::Color(crate::color(
self.config.theme.text_primary,
))),
)
.padding([8, 10])
.class(ferese_theme::controls::surface(
@@ -350,7 +378,9 @@ impl Transition {
return self.to;
}
let t = (now.saturating_duration_since(self.started).as_secs_f32() / self.duration.as_secs_f32()).min(1.);
let t = (now.saturating_duration_since(self.started).as_secs_f32()
/ self.duration.as_secs_f32())
.min(1.);
self.from + (self.to - self.from) * t * t * (3. - 2. * t)
}
@@ -375,14 +405,17 @@ impl WorkspaceUi {
Duration::ZERO
};
self.transitions
.retain(|id, _| snapshot.workspaces.iter().any(|workspace| workspace.id == *id));
self.transitions.retain(|id, _| {
snapshot
.workspaces
.iter()
.any(|workspace| workspace.id == *id)
});
for workspace in &snapshot.workspaces {
let active = snapshot
.outputs
.iter()
.any(|output| Some(output.id) == workspace.output && output.active_workspace == workspace.id);
let active = snapshot.outputs.iter().any(|output| {
Some(output.id) == workspace.output && output.active_workspace == workspace.id
});
let target = if active { 1. } else { 0. };
let transition = self.transitions.entry(workspace.id).or_insert(Transition {
from: target,
@@ -438,12 +471,15 @@ impl WorkspaceUi {
}
pub(super) fn transition(&self, workspace: u64, active: bool) -> Transition {
self.transitions.get(&workspace).copied().unwrap_or(Transition {
from: if active { 1. } else { 0. },
to: if active { 1. } else { 0. },
started: Instant::now(),
duration: Duration::ZERO,
})
self.transitions
.get(&workspace)
.copied()
.unwrap_or(Transition {
from: if active { 1. } else { 0. },
to: if active { 1. } else { 0. },
started: Instant::now(),
duration: Duration::ZERO,
})
}
pub(super) fn apps(&self, workspace: u64, snapshot: &ShellSnapshot) -> Vec<icon::Handle> {
@@ -521,8 +557,15 @@ mod tests {
assert!(!ui.icons_loading);
assert_eq!(ui.load_icons(&snapshot, true).units(), 1);
assert!(ui.icons_loading);
assert!(ui.entries.is_none(), "scheduling must not perform filesystem discovery");
assert_eq!(ui.apps(1, &snapshot).len(), 1, "new apps have a bundled placeholder");
assert!(
ui.entries.is_none(),
"scheduling must not perform filesystem discovery"
);
assert_eq!(
ui.apps(1, &snapshot).len(),
1,
"new apps have a bundled placeholder"
);
assert_eq!(ui.load_icons(&snapshot, true).units(), 0);
assert!(ui.entries.is_none());
assert!(ui.icons.is_empty());
@@ -535,8 +578,10 @@ mod tests {
add_window(&mut snapshot, "running");
ui.entries = Some(Arc::new(Vec::new()));
for id in ["running", "closed"] {
ui.icons
.insert(id.into(), icon::from_svg_bytes(ferese_theme::icons::APPLICATION));
ui.icons.insert(
id.into(),
icon::from_svg_bytes(ferese_theme::icons::APPLICATION),
);
}
assert_eq!(ui.load_icons(&snapshot, true).units(), 0);
assert!(ui.icons.contains_key("running"));
@@ -547,7 +592,10 @@ mod tests {
assert!(ui.icons.is_empty());
assert_eq!(ui.icons.capacity(), 0);
assert!(ui.entries.is_none());
assert!(ui.icons_loading, "an in-flight worker still owns its discovery request");
assert!(
ui.icons_loading,
"an in-flight worker still owns its discovery request"
);
}
#[test]
@@ -565,10 +613,10 @@ mod tests {
shell.snapshot = snapshot(1);
add_window(&mut shell.snapshot, "existing");
add_window(&mut shell.snapshot, "discovered");
shell
.workspace_ui
.icons
.insert("existing".into(), icon::from_svg_bytes(ferese_theme::icons::SETTINGS));
shell.workspace_ui.icons.insert(
"existing".into(),
icon::from_svg_bytes(ferese_theme::icons::SETTINGS),
);
use std::hash::{Hash, Hasher};
let identity = |handle: &icon::Handle| {
let mut hash = std::collections::hash_map::DefaultHasher::new();
@@ -582,7 +630,10 @@ mod tests {
add_window(&mut shell.snapshot, "arrived-during-discovery");
drop(shell.workspace_icons_loaded(Ok(IconIndex {
entries: Arc::new(Vec::new()),
icons: HashMap::from([("discovered".into(), icon::from_svg_bytes(ferese_theme::icons::OVERVIEW))]),
icons: HashMap::from([(
"discovered".into(),
icon::from_svg_bytes(ferese_theme::icons::OVERVIEW),
)]),
})));
assert_eq!(identity(&shell.workspace_ui.icons["existing"]), existing);
assert!(shell.workspace_ui.icons.contains_key("discovered"));
@@ -609,7 +660,8 @@ mod tests {
assert_eq!(ui.transition(1, true).progress(midpoint), 0.5);
assert_eq!(ui.transition(2, false).progress(midpoint), 0.5);
assert_eq!(
ui.transition(1, true).progress(midpoint + Duration::from_millis(160)),
ui.transition(1, true)
.progress(midpoint + Duration::from_millis(160)),
1.
);
ui.update_at(
@@ -623,7 +675,10 @@ mod tests {
assert_eq!(ui.transition(1, false).progress(midpoint), 0.);
assert_eq!(ui.transition(2, true).progress(midpoint), 1.);
assert!(!ui.transition(2, true).active(midpoint));
assert!(ui.entries.is_none(), "other styles must not scan desktop applications");
assert!(
ui.entries.is_none(),
"other styles must not scan desktop applications"
);
ui.update_at(&ShellSnapshot::default(), settings, midpoint);
assert!(ui.transitions.is_empty());
}
@@ -733,18 +788,26 @@ mod tests {
let viewport = Rectangle::with_size(Size::new(1200., 36.));
let mut view = app.view_layer(bar);
tree.diff(view.as_widget_mut());
let node = view
.as_widget_mut()
.layout(tree, renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
let node = view.as_widget_mut().layout(
tree,
renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let mut find = FindBounds {
target,
bar,
window: Some(bar),
found: None,
};
view.as_widget_mut()
.operate(tree, Layout::new(&node), renderer, &mut operation::black_box(&mut find));
let bounds = find.found.expect("the real panel must contain the workspace target");
view.as_widget_mut().operate(
tree,
Layout::new(&node),
renderer,
&mut operation::black_box(&mut find),
);
let bounds = find
.found
.expect("the real panel must contain the workspace target");
let inside = Point::new(bounds.center_x(), bounds.center_y());
let (event, cursor) = match pointer {
Pointer::Inside | Pointer::Beside => {
@@ -758,8 +821,14 @@ mod tests {
mouse::Cursor::Available(position),
)
}
Pointer::LeaveAvailable => (Event::Mouse(mouse::Event::CursorLeft), mouse::Cursor::Available(inside)),
Pointer::LeaveUnavailable => (Event::Mouse(mouse::Event::CursorLeft), mouse::Cursor::Unavailable),
Pointer::LeaveAvailable => (
Event::Mouse(mouse::Event::CursorLeft),
mouse::Cursor::Available(inside),
),
Pointer::LeaveUnavailable => (
Event::Mouse(mouse::Event::CursorLeft),
mouse::Cursor::Unavailable,
),
};
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
@@ -787,7 +856,9 @@ mod tests {
(bounds, hover_messages)
}
let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
@@ -821,8 +892,12 @@ mod tests {
}
}
let mut tree = widget::Tree::empty();
let (bounds, messages) = send_pointer(&mut app, &renderer, &mut tree, Pointer::Inside, false);
assert!(messages > 0, "{style:?} must enter its actual workspace widget");
let (bounds, messages) =
send_pointer(&mut app, &renderer, &mut tree, Pointer::Inside, false);
assert!(
messages > 0,
"{style:?} must enter its actual workspace widget"
);
assert!(app.workspace_ui.hovered.is_some());
let serial = app.workspace_ui.serial;
if visible {
@@ -881,7 +956,10 @@ mod tests {
shell.workspace_ui.hovered.is_some(),
"leaving another surface must not cancel this hover"
);
drop(shell.handle_event(cosmic::iced::Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), bar));
drop(shell.handle_event(
cosmic::iced::Event::Mouse(cosmic::iced::mouse::Event::CursorLeft),
bar,
));
assert!(shell.workspace_ui.hovered.is_none());
assert!(shell.workspace_ui.tooltip.is_none());
drop(shell.show_workspace_tooltip(serial, bounds));
@@ -945,7 +1023,10 @@ mod tests {
let _ = shell.hover_workspace(bar, 2, second, true);
let current = shell.workspace_ui.serial;
let _ = shell.show_workspace_tooltip(current, bounds);
shell.snapshot.workspaces.retain(|workspace| workspace.id != 2);
shell
.snapshot
.workspaces
.retain(|workspace| workspace.id != 2);
let _ = shell.update_workspace_ui();
assert!(shell.workspace_ui.tooltip.is_none());
assert!(shell.workspace_ui.hovered.is_none());