Compare commits

..

2 commits

Author SHA1 Message Date
Jeremy Soller
ae3f722521
feat: use rich_text for notifications-applet (#1387)
Some checks failed
Continuous Integration / formatting (push) Has been cancelled
Continuous Integration / linting (push) Has been cancelled
Validate .desktop files / validate (push) Has been cancelled
2026-04-23 09:53:24 -06:00
Piotr
fc3c044c88 feat: use rich_text for notifications-applet 2026-04-20 12:28:11 +02:00
6 changed files with 48 additions and 362 deletions

View file

@ -187,11 +187,6 @@ impl DockItem {
dot_border_radius: [f32; 4],
window_id: window::Id,
filter: Option<&dyn Fn(&ToplevelInfo) -> bool>,
// Yoda: multiplier on the computed icon size (1.0 = default,
// >1.0 = magnified e.g. on hover for the macOS Tahoe effect).
// Applied to the icon's rendered width/height only — indicator
// dot and surrounding layout stay at base size.
icon_scale: f32,
) -> Element<'_, Message> {
let Self {
toplevels,
@ -210,35 +205,17 @@ impl DockItem {
};
let toplevel_count = filtered_toplevels.len();
// Cairo-like : pastille plus petite + atténuée quand toutes les fenêtres
// de cette app sont minimisées.
let all_minimized = toplevel_count > 0
&& filtered_toplevels
.iter()
.all(|(info, _)| info.state.contains(&State::Minimized));
let app_icon = AppletIconData::new(applet);
// Yoda: scaled icon size for hover magnification. Clamped so
// tiny floats don't round to 0 and huge ones stay within u16.
let scaled_icon_size = ((f32::from(app_icon.icon_size) * icon_scale).round() as i32)
.clamp(1, u16::MAX as i32) as u16;
let cosmic_icon = cosmic::widget::icon(
fde::IconSource::from_unknown(desktop_info.icon().unwrap_or_default()).as_cosmic_icon(),
)
// sets the preferred icon size variant
.size(128)
.width(scaled_icon_size.into())
.height(scaled_icon_size.into());
.width(app_icon.icon_size.into())
.height(app_icon.icon_size.into());
let indicator = {
// Padding réduit quand minimisée → pastille plus petite.
let effective_radius = if all_minimized {
(app_icon.dot_radius * 0.55).max(1.0)
} else {
app_icon.dot_radius
};
let container = if toplevel_count <= 1 {
vertical_space().height(Length::Fixed(0.0))
} else {
@ -252,34 +229,22 @@ impl DockItem {
}
}
.apply(container)
.padding(effective_radius);
.padding(app_icon.dot_radius);
if toplevel_count == 0 {
container
} else {
container.class(theme::Container::custom(move |theme| {
let cosmic = theme.cosmic();
let accent: iced::Color = cosmic.accent_color().into();
let on_bg: iced::Color = cosmic.on_bg_color().into();
// Teinte neutre atténuée quand toutes les fenêtres sont minimisées.
let muted = iced::Color { a: 0.45, ..on_bg };
let fill = if all_minimized {
muted
} else if is_focused {
accent
container.class(theme::Container::custom(move |theme| container::Style {
background: if is_focused {
Some(Background::Color(theme.cosmic().accent_color().into()))
} else {
on_bg
};
container::Style {
background: Some(Background::Color(fill)),
border: Border {
radius: dot_border_radius.into(),
..Default::default()
},
Some(Background::Color(theme.cosmic().on_bg_color().into()))
},
border: Border {
radius: dot_border_radius.into(),
..Default::default()
}
},
..Default::default()
}))
}
};
@ -409,21 +374,6 @@ struct CosmicAppList {
output_list: FxHashMap<WlOutput, OutputInfo>,
locales: Vec<String>,
hovered_toplevel: Option<ExtForeignToplevelHandleV1>,
/// Yoda: which dock icon the pointer is currently over (for hover
/// magnification). None = no dock icon hovered.
hovered_dock_item: Option<DockItemId>,
/// Yoda: animated "virtual cursor" center used by the fisheye
/// formula — lerps toward the real hovered icon's center on each
/// AnimTick, so the bell curve slides smoothly from one icon to the
/// next instead of snapping.
anim_hover_center: Option<(f32, f32)>,
/// Yoda: fade-in/out intensity of the magnification effect
/// (0.0 = icons flat, 1.0 = full fisheye). Targets 1.0 while the
/// pointer is over any dock icon, 0.0 otherwise. Lerped on AnimTick.
anim_hover_intensity: f32,
/// Yoda: timestamp of the last AnimTick, for dt-based exponential
/// smoothing. `None` on first tick.
anim_last_tick: Option<std::time::Instant>,
overflow_favorites_popup: Option<window::Id>,
overflow_active_popup: Option<window::Id>,
}
@ -439,13 +389,6 @@ enum Message {
Wayland(WaylandUpdate),
PinApp(u32),
UnpinApp(u32),
/// Yoda: pointer entered (Some) or left (None) a dock icon — drives
/// the macOS Tahoe-style hover magnification effect.
DockItemHover(Option<DockItemId>),
/// Yoda: ticked at ~60fps by the animation subscription. Advances
/// anim_hover_center + anim_hover_intensity toward their targets so
/// the fisheye effect transitions smoothly instead of snapping.
AnimTick(std::time::Instant),
Popup(u32, window::Id),
Pressed(window::Id),
ToplevelListPopup(u32, window::Id),
@ -704,79 +647,6 @@ impl CosmicAppList {
.collect::<Vec<_>>();
}
/// Yoda: macOS-Tahoe fisheye-style magnification. Returns the
/// per-icon size multiplier based on the distance (in pixels) from
/// the currently hovered icon's center to this icon's center.
///
/// Uses a gaussian bell curve so the hovered icon peaks at
/// 1.0 + PEAK, immediate neighbors still bulge noticeably, and icons
/// further away relax back to 1.0× — that's the smooth neighbour
/// deformation people associate with the macOS Dock.
///
/// Falls back to binary 1.3×/1.0× when the rectangle tracker hasn't
/// populated yet (first render, or just after layout changes).
///
/// Uses the animated hover center (anim_hover_center) and intensity
/// (anim_hover_intensity) so inter-icon transitions slide smoothly
/// and the whole effect fades in/out at the dock's edges.
fn icon_scale_for(&self, id: &DockItemId) -> f32 {
const PEAK: f32 = 0.35;
// sigma expressed in multiples of the hovered icon's size —
// 1.4 means the ±1 neighbors sit ~0.7σ away and still bulge
// visibly, while ±3+ has collapsed to ~1.0× (fisheye footprint
// close to 5 icons wide, Tahoe-ish).
const SIGMA_FACTOR: f32 = 1.4;
// No intensity at all → skip the rest.
if self.anim_hover_intensity < 0.001 {
return 1.0;
}
// Prefer the animated center (smooth); fall back to the real
// hovered icon's center (first render, before tick fires).
let hover_center = self.anim_hover_center.or_else(|| {
let hovered_id = self.hovered_dock_item.as_ref()?;
let r = self.rectangles.get(hovered_id)?;
Some((r.x + r.width / 2.0, r.y + r.height / 2.0))
});
let Some(hover_center) = hover_center else {
// No coords yet — visibly peak on the exact hovered id so
// the very first frame still responds.
return if self.hovered_dock_item.as_ref() == Some(id) {
1.0 + PEAK * self.anim_hover_intensity
} else {
1.0
};
};
let this_rect = match self.rectangles.get(id) {
Some(r) => r,
None => return 1.0,
};
let is_horizontal = matches!(
self.core.applet.anchor,
PanelAnchor::Top | PanelAnchor::Bottom
);
let this_center = if is_horizontal {
this_rect.x + this_rect.width / 2.0
} else {
this_rect.y + this_rect.height / 2.0
};
let hover_axis = if is_horizontal { hover_center.0 } else { hover_center.1 };
let distance = (this_center - hover_axis).abs();
let icon_extent = if is_horizontal {
this_rect.width
} else {
this_rect.height
}
.max(1.0);
let sigma = icon_extent * SIGMA_FACTOR;
// exp(-t²) bell curve, t = distance / sigma
let t = distance / sigma;
1.0 + PEAK * self.anim_hover_intensity * (-t * t).exp()
}
fn is_on_current_monitor_and_workspace(&self, toplevel_info: &ToplevelInfo) -> bool {
use cosmic_app_list_config::ToplevelFilter;
@ -1712,52 +1582,6 @@ impl cosmic::Application for CosmicAppList {
Message::GpuRequest(gpus) => {
self.gpus = gpus;
}
Message::DockItemHover(id) => {
self.hovered_dock_item = id;
// Seed the animated center on the very first hover so
// the bell doesn't "fly in" from (0,0).
if self.anim_hover_center.is_none()
&& let Some(hovered_id) = self.hovered_dock_item.as_ref()
&& let Some(r) = self.rectangles.get(hovered_id)
{
self.anim_hover_center = Some((
r.x + r.width / 2.0,
r.y + r.height / 2.0,
));
}
}
Message::AnimTick(now) => {
// dt-based exponential smoothing: reach ~99% of the
// target in ~120ms at 60fps (about 7 ticks).
let dt = self
.anim_last_tick
.map(|prev| now.saturating_duration_since(prev).as_secs_f32())
.unwrap_or(0.016)
.min(0.1); // clamp so a long pause doesn't snap
self.anim_last_tick = Some(now);
// Intensity: target 1.0 when any icon is hovered, 0.0 else.
let intensity_target = if self.hovered_dock_item.is_some() { 1.0 } else { 0.0 };
let tau = 0.060_f32; // time-constant (s); smaller = snappier
let alpha = 1.0 - (-dt / tau).exp();
self.anim_hover_intensity += (intensity_target - self.anim_hover_intensity) * alpha;
// Hovered-center smoothing: chase the real rect's center.
if let Some(hovered_id) = self.hovered_dock_item.as_ref()
&& let Some(r) = self.rectangles.get(hovered_id)
{
let target = (r.x + r.width / 2.0, r.y + r.height / 2.0);
let current = self.anim_hover_center.unwrap_or(target);
self.anim_hover_center = Some((
current.0 + (target.0 - current.0) * alpha,
current.1 + (target.1 - current.1) * alpha,
));
} else if self.anim_hover_intensity < 0.01 {
// Nothing hovered + intensity faded out → forget the
// animated center so the next hover seeds fresh.
self.anim_hover_center = None;
}
}
Message::OpenActive => {
let create_new = self.overflow_active_popup.is_none();
let mut cmds = vec![self.close_popups()];
@ -1939,9 +1763,7 @@ impl cosmic::Application for CosmicAppList {
.filter(|(info, _)| self.is_on_current_monitor_and_workspace(info))
.any(|y| focused_item.contains(&y.0.foreign_toplevel));
let dock_id = dock_item.id;
let icon_scale = self.icon_scale_for(&DockItemId::from(dock_id));
let tooltip = self.core
self.core
.applet
.applet_tooltip::<Message>(
dock_item.as_icon(
@ -1954,7 +1776,6 @@ impl cosmic::Application for CosmicAppList {
dot_radius,
self.core.main_window_id().unwrap(),
Some(&|info| self.is_on_current_monitor_and_workspace(info)),
icon_scale,
),
dock_item
.desktop_info
@ -1964,10 +1785,7 @@ impl cosmic::Application for CosmicAppList {
self.popup.is_some(),
Message::Surface,
None,
);
cosmic::widget::mouse_area(tooltip)
.on_enter(Message::DockItemHover(Some(DockItemId::from(dock_id))))
.on_exit(Message::DockItemHover(None))
)
.into()
})
.collect();
@ -2018,10 +1836,6 @@ impl cosmic::Application for CosmicAppList {
dot_radius,
self.core.main_window_id().unwrap(),
Some(&|info| self.is_on_current_monitor_and_workspace(info)),
// Yoda: no magnification on DnD-preview icons — these
// float around as the user drags, so static 1.0 is
// less visually confusing.
1.0,
),
);
} else if self.is_listening_for_dnd && self.pinned_list.is_empty() {
@ -2061,10 +1875,8 @@ impl cosmic::Application for CosmicAppList {
.iter()
.filter(|(info, _)| self.is_on_current_monitor_and_workspace(info))
.any(|y| focused_item.contains(&y.0.foreign_toplevel));
let dock_id = dock_item.id;
let icon_scale = self.icon_scale_for(&DockItemId::from(dock_id));
let tooltip = self.core
self.core
.applet
.applet_tooltip(
dock_item.as_icon(
@ -2077,7 +1889,6 @@ impl cosmic::Application for CosmicAppList {
dot_radius,
self.core.main_window_id().unwrap(),
Some(&|info| self.is_on_current_monitor_and_workspace(info)),
icon_scale,
),
dock_item
.desktop_info
@ -2087,10 +1898,7 @@ impl cosmic::Application for CosmicAppList {
self.popup.is_some(),
Message::Surface,
None,
);
cosmic::widget::mouse_area(tooltip)
.on_enter(Message::DockItemHover(Some(DockItemId::from(dock_id))))
.on_exit(Message::DockItemHover(None))
)
.into()
})
.collect();
@ -2505,10 +2313,6 @@ impl cosmic::Application for CosmicAppList {
dot_radius,
id,
Some(&|info| self.is_on_current_monitor_and_workspace(info)),
// Yoda: icons in the overflow popup are
// already smaller-grid — keep them at 1.0
// so the popup doesn't reshuffle on hover.
1.0,
),
dock_item
.desktop_info
@ -2617,10 +2421,6 @@ impl cosmic::Application for CosmicAppList {
dot_radius,
id,
Some(&|info| self.is_on_current_monitor_and_workspace(info)),
// Yoda: popup icons stay at 1.0 (hover
// magnification is applied to the main
// dock row only).
1.0,
),
dock_item
.desktop_info
@ -2679,21 +2479,7 @@ impl cosmic::Application for CosmicAppList {
}
fn subscription(&self) -> Subscription<Message> {
// Yoda: ~60fps animation ticks for the fisheye magnification.
// Only emitted when an animation is actually in progress (hover
// intensity >0 OR still fading out) — keeps the panel idle when
// the pointer is nowhere near the dock.
let anim_active = self.hovered_dock_item.is_some()
|| self.anim_hover_intensity > 0.001;
let anim_subscription = if anim_active {
cosmic::iced::time::every(std::time::Duration::from_millis(16))
.map(Message::AnimTick)
} else {
Subscription::none()
};
Subscription::batch([
anim_subscription,
wayland_subscription().map(Message::Wayland),
listen_with(|e, _, id| match e {
cosmic::iced::core::Event::PlatformSpecific(event::PlatformSpecific::Wayland(

View file

@ -388,10 +388,7 @@ impl CaptureData {
},
)
.unwrap();
if let Err(err) = self.conn.flush() {
tracing::error!("Wayland flush failed during screencopy session create: {err}");
return None;
}
self.conn.flush().unwrap();
let formats = session
.wait_while(|data| data.formats.is_none())
@ -440,10 +437,7 @@ impl CaptureData {
session: capture_session.clone(),
},
);
if let Err(err) = self.conn.flush() {
tracing::error!("Wayland flush failed during screencopy capture: {err}");
return None;
}
self.conn.flush().unwrap();
// TODO: wait for server to release buffer?
let res = session
@ -715,10 +709,7 @@ pub(crate) fn wayland_handler(
if app_data.exit {
break;
}
if let Err(err) = event_loop.dispatch(None, &mut app_data) {
tracing::error!("Wayland event loop terminated: {err}");
break;
}
event_loop.dispatch(None, &mut app_data).unwrap();
}
}

View file

@ -487,31 +487,11 @@ impl cosmic::Application for Audio {
.icon_button(self.output_icon_name())
.on_press_down(Message::TogglePopup);
const WHEEL_STEP: f32 = 5.0; // 5% par cran logique
// Wayland axis_v120 envoie un cran physique en rafale de plusieurs
// ScrollDelta::Pixels (58 events ~1520px), pour ~120px par cran. On
// accumule ces sub-events dans un thread_local et on n'émet qu'au
// passage d'un seuil — sinon `signum()` faisait croire que chaque
// sub-event = un cran, et un seul cran physique faisait chuter le
// volume jusqu'à 0 ("coupe le son").
const PIXEL_THRESHOLD: f32 = 15.0; // px par cran logique
std::thread_local! {
static PIXEL_ACC: std::cell::Cell<f32> = const { std::cell::Cell::new(0.0) };
}
const WHEEL_STEP: f32 = 5.0; // 5% per wheel event
let btn = crate::mouse_area::MouseArea::new(btn).on_mouse_wheel(|delta| {
let scroll_vector = match delta {
iced::mouse::ScrollDelta::Lines { y, .. } => {
PIXEL_ACC.with(|a| a.set(0.0));
y * WHEEL_STEP
}
iced::mouse::ScrollDelta::Pixels { y, .. } => {
PIXEL_ACC.with(|acc_cell| {
let acc = acc_cell.get() + y;
let steps = (acc / PIXEL_THRESHOLD).trunc();
acc_cell.set(acc - steps * PIXEL_THRESHOLD);
steps * WHEEL_STEP
})
}
iced::mouse::ScrollDelta::Lines { y, .. } => y.signum() * WHEEL_STEP, // -1/0/1
iced::mouse::ScrollDelta::Pixels { y, .. } => y.signum(), // -1/0/1
};
if scroll_vector == 0.0 {
return Message::Ignore;
@ -519,7 +499,7 @@ impl cosmic::Application for Audio {
let new_volume = (self.model.sink_volume as f64 + (scroll_vector as f64))
.clamp(0.0, self.max_sink_volume as f64);
Message::SetSinkVolume(new_volume.round() as u32)
Message::SetSinkVolume(new_volume as u32)
});
let playback_buttons = (!self.core.applet.suggested_bounds.as_ref().is_some_and(|c| {

View file

@ -16,7 +16,7 @@ use cosmic::{
Alignment, Length, Subscription,
advanced::text::{Ellipsize, EllipsizeHeightLimit},
platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup},
widget::{self, column, row},
widget::{self, column, rich_text, row},
window,
},
surface, theme,
@ -26,7 +26,7 @@ use cosmic::{
use cosmic::iced::futures::executor::block_on;
use cosmic_notifications_config::NotificationsConfig;
use cosmic_notifications_util::{ActionId, Image, Notification};
use cosmic_notifications_util::{ActionId, Image, Notification, markup};
use std::{borrow::Cow, collections::HashMap, path::PathBuf, sync::LazyLock};
use subscriptions::notifications::{self, NotificationsAppletProxy};
use tokio::sync::mpsc::Sender;
@ -456,8 +456,11 @@ impl cosmic::Application for Notifications {
column![
text::body(n.summary.lines().next().unwrap_or_default())
.width(Length::Fill),
text::caption(n.body.lines().next().unwrap_or_default())
.width(Length::Fill)
Element::from(
rich_text(markup::html_to_spans(&n.body))
.size(12.0)
.width(Length::Fill)
)
]
)
.width(Length::Fill),

View file

@ -12,44 +12,26 @@ fn main() -> cosmic::iced::Result {
};
let start = applet.rfind('/').map_or(0, |v| v + 1);
let cmd = applet.as_str()[start..].to_string();
let cmd = &applet.as_str()[start..];
tracing::info!("Starting `{cmd}` with version {VERSION}");
let cmd_for_run = cmd.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
match cmd_for_run.as_str() {
"cosmic-app-list" => cosmic_app_list::run(),
"cosmic-applet-a11y" => cosmic_applet_a11y::run(),
"cosmic-applet-audio" => cosmic_applet_audio::run(),
"cosmic-applet-battery" => cosmic_applet_battery::run(),
"cosmic-applet-bluetooth" => cosmic_applet_bluetooth::run(),
"cosmic-applet-minimize" => cosmic_applet_minimize::run(),
"cosmic-applet-network" => cosmic_applet_network::run(),
"cosmic-applet-notifications" => cosmic_applet_notifications::run(),
"cosmic-applet-power" => cosmic_applet_power::run(),
"cosmic-applet-status-area" => cosmic_applet_status_area::run(),
"cosmic-applet-tiling" => cosmic_applet_tiling::run(),
"cosmic-applet-time" => cosmic_applet_time::run(),
"cosmic-applet-workspaces" => cosmic_applet_workspaces::run(),
"cosmic-applet-input-sources" => cosmic_applet_input_sources::run(),
"cosmic-panel-button" => cosmic_panel_button::run(),
_ => Ok(()),
}
}));
match result {
Ok(r) => r,
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "<non-string panic>".to_string());
tracing::error!(
"`{cmd}` panicked (likely compositor disconnect), exiting cleanly: {msg}"
);
Ok(())
}
match cmd {
"cosmic-app-list" => cosmic_app_list::run(),
"cosmic-applet-a11y" => cosmic_applet_a11y::run(),
"cosmic-applet-audio" => cosmic_applet_audio::run(),
"cosmic-applet-battery" => cosmic_applet_battery::run(),
"cosmic-applet-bluetooth" => cosmic_applet_bluetooth::run(),
"cosmic-applet-minimize" => cosmic_applet_minimize::run(),
"cosmic-applet-network" => cosmic_applet_network::run(),
"cosmic-applet-notifications" => cosmic_applet_notifications::run(),
"cosmic-applet-power" => cosmic_applet_power::run(),
"cosmic-applet-status-area" => cosmic_applet_status_area::run(),
"cosmic-applet-tiling" => cosmic_applet_tiling::run(),
"cosmic-applet-time" => cosmic_applet_time::run(),
"cosmic-applet-workspaces" => cosmic_applet_workspaces::run(),
"cosmic-applet-input-sources" => cosmic_applet_input_sources::run(),
"cosmic-panel-button" => cosmic_panel_button::run(),
_ => Ok(()),
}
}

View file

@ -1,56 +0,0 @@
#!/usr/bin/env bash
# Recompile et déploie le fork local cosmic-applets (multiplexor cosmic-applets +
# binaire séparé cosmic-app-list).
#
# Cibles : /usr/local/bin/cosmic-applets et /usr/local/bin/cosmic-app-list
# (binaires compilés manuellement, hors pacman, qui priment sur ceux du paquet
# via $PATH /usr/local/bin avant /usr/bin).
#
# Branche de déploiement : yoda-dock-magnification (contient les commits
# magnification + le fix Wayland error handling).
set -euo pipefail
REPO="/home/lionel/Devels/cosmic-applets"
TARGETS=(
"cosmic-applets"
"cosmic-app-list"
"cosmic-panel-button"
)
BIN_DIR="/usr/local/bin"
cd "$REPO"
echo "==> Branche actuelle : $(git branch --show-current)"
echo "==> Build release (workspace)..."
cargo build --release --workspace
STAMP=$(date +%Y%m%d-%H%M%S)
for bin in "${TARGETS[@]}"; do
src="$REPO/target/release/$bin"
dst="$BIN_DIR/$bin"
if [[ ! -f "$src" ]]; then
echo "!! Binaire absent après build : $src" >&2
exit 1
fi
if [[ -f "$dst" ]]; then
echo "==> Backup $dst"
sudo cp -a "$dst" "${dst}.bak.${STAMP}"
fi
echo "==> Install $src -> $dst"
sudo install -m755 "$src" "$dst"
done
echo "==> Kill des process applet en cours (cosmic-panel relancera à la demande)"
pkill -u "$USER" -f "/usr/local/bin/cosmic-applet" || true
pkill -u "$USER" -f "/usr/local/bin/cosmic-app-list" || true
pkill -u "$USER" -f "/usr/local/bin/cosmic-panel-button" || true
echo "==> Vérif"
for bin in "${TARGETS[@]}"; do
file "$BIN_DIR/$bin"
done
echo "OK — relogin recommandé pour repartir sur des process applet propres."