Align recording dialog behavior and shell theme styling

This commit is contained in:
Samuel Onoja committed 2026-09-28 09:15:13 +01:00
1 parent 24e15f76cb
commit 8832a9bf2e
9 files changed
+263 -26

No files matched your search

+52 -4
View File
@@ -47,6 +47,32 @@ pub fn foreground(background: Color, preferred: Color) -> Color {
} }
} }
/// Prefer the theme's foreground for filled controls. A small shade adjustment
/// is less disruptive than flipping light text to black when selecting a row.
/// Very bright/dark accents still use a contrasting foreground rather than
/// changing the user's accent into a substantially different color.
pub fn accent_pair(background: Color, preferred: Color) -> (Color, Color) {
let on = composite(preferred, background);
let shade = if luminance(on) > luminance(background) {
Color::BLACK
} else {
Color::WHITE
};
for step in 0..=20 {
let fill = composite(
Color {
a: step as f32 / 100.,
..shade
},
background,
);
if contrast(fill, on) >= 4.5 {
return (fill, on);
}
}
(background, foreground(background, preferred))
}
/// Resolve configured accent transparency before passing an opaque color to a toolkit. /// Resolve configured accent transparency before passing an opaque color to a toolkit.
pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb { pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb {
rgba(composite(accent, surface)).color rgba(composite(accent, surface)).color
@@ -56,8 +82,7 @@ pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::pale
pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) { pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into(); let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] { for component in [&mut native.accent, &mut native.accent_button] {
let base = composite(component.base.into(), surface); let (base, on) = accent_pair(composite(component.base.into(), surface), preferred);
let on = foreground(base, preferred);
component.hover = rgba(readable_fill( component.hover = rgba(readable_fill(
composite(component.hover.into(), surface), composite(component.hover.into(), surface),
base, base,
@@ -68,7 +93,11 @@ pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
base, base,
on, on,
)); ));
component.selected = rgba(composite(component.selected.into(), surface)); component.selected = rgba(readable_fill(
composite(component.selected.into(), surface),
base,
on,
));
component.base = rgba(base); component.base = rgba(base);
component.on = rgba(on); component.on = rgba(on);
component.selected_text = rgba(foreground( component.selected_text = rgba(foreground(
@@ -170,7 +199,7 @@ mod tests {
for surface in [Color::BLACK, Color::WHITE] { for surface in [Color::BLACK, Color::WHITE] {
for alpha in [0., 0.2, 0.5, 1.] { for alpha in [0., 0.2, 0.5, 1.] {
let fill = composite(Color::from_rgba8(r, g, b, alpha), surface); let fill = composite(Color::from_rgba8(r, g, b, alpha), surface);
let on = foreground(fill, Color::from_rgb8(120, 130, 140)); let (fill, on) = accent_pair(fill, Color::from_rgb8(120, 130, 140));
assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}"); assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}");
} }
} }
@@ -179,6 +208,25 @@ mod tests {
} }
} }
#[test] #[test]
fn blue_accent_preserves_light_theme_text_in_every_state() {
let preferred = Color::from_rgb8(244, 247, 251);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(Color::from_rgb8(61, 123, 230)).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let actual = style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
assert_eq!(actual.icon_color, Some(preferred));
}
}
#[test]
fn native_button_states_keep_text_and_icons_readable() { fn native_button_states_keep_text_and_icons_readable() {
for accent in [ for accent in [
Color::BLACK, Color::BLACK,
+3 -2
View File
@@ -146,8 +146,9 @@ these six visually distinct.
| `shadow` | `"#00000055"` | Window shadow color | | `shadow` | `"#00000055"` | Window shadow color |
Ferese derives the text and icon color for filled accent controls from the Ferese derives the text and icon color for filled accent controls from the
configured theme. It keeps the configured text color when it reaches a 4.5:1 configured theme. It keeps the configured text color and, when necessary, shades
contrast ratio; otherwise it chooses black or white. The same rule is shared by the accent fill slightly to reach a 4.5:1 contrast ratio. If that would require a
large color change, it chooses black or white instead. The same rule is shared by
native dialogs, settings selections, and notification badges. Hover, pressed, native dialogs, settings selections, and notification badges. Hover, pressed,
and disabled accent-button states use the same policy. Small filled controls and disabled accent-button states use the same policy. Small filled controls
resolve accent transparency against their theme surface so wallpaper cannot resolve accent transparency against their theme surface so wallpaper cannot
+8 -3
View File
@@ -27,10 +27,15 @@ may remain at the path shown in the error.
Use a Wayland screen-sharing or recording source in your application. Ferese Use a Wayland screen-sharing or recording source in your application. Ferese
opens a display picker. Select a display and press **Share**. Nothing is selected opens a display picker. Select a display and press **Share**. Nothing is selected
by default. Applications that request multiple displays can share up to four. by default. Applications that request multiple displays can share up to four.
The picker uses your Ferese theme colors, font, and shell corner radius. The picker uses your Ferese theme colors, font, and shell corner radius. Its
background follows `theme.material.style`: opaque in solid mode, or translucent
using `theme.surface.popover.opacity`. Text and icons remain opaque.
A **screen sharing** window remains visible while sharing. Press **Stop sharing** Other applications keep a **screen sharing** window with a **Stop sharing** button.
or close that window to revoke the session. Closing the requesting application, Ferese’s built-in recorder uses the top-bar stop control instead, leaving your
windows accessible after display selection. The command-line recorder keeps the
sharing window when it is not launched by the shell. Press **Stop sharing** or
close the sharing window to revoke an ordinary application’s session. Closing the requesting application,
locking Ferese, disconnecting the selected display, or changing its capture size locking Ferese, disconnecting the selected display, or changing its capture size
also ends sharing. A new request needs fresh consent; selections are not saved. also ends sharing. A new request needs fresh consent; selections are not saved.
Everything visible on a shared display, including notifications, is included. Everything visible on a shared display, including notifications, is included.
+113 -8
View File
@@ -73,12 +73,80 @@ struct Session {
phase: Phase, phase: Phase,
multiple: bool, multiple: bool,
cursor: bool, cursor: bool,
bar_controlled: bool,
cancel: Arc<Cancel>, cancel: Arc<Cancel>,
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct Backend { pub struct Backend {
sessions: Arc<Mutex<HashMap<String, Session>>>, sessions: Arc<Mutex<HashMap<String, Session>>>,
} }
// This private interface only transfers stop controls to the shell's own recorder.
// The ordinary portal picker and all session revocation rules still apply.
struct RecorderControl(Backend);
#[zbus::interface(name = "org.ferese.ScreenRecorder")]
impl RecorderControl {
async fn use_bar_controls(
&self,
session_handle: OwnedObjectPath,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
let sender = header
.sender()
.ok_or_else(|| error("Missing recorder caller"))?;
if !owns_session(sender.as_str(), session_handle.as_str()) {
return Err(zbus::fdo::Error::AccessDenied(
"Not your recording session".into(),
));
}
let dbus = zbus::fdo::DBusProxy::new(connection).await?;
let pid = dbus
.get_connection_unix_process_id(sender.clone().into())
.await?;
if !shell_recorder(pid) {
return Err(zbus::fdo::Error::AccessDenied(
"The shell must own the recording controls".into(),
));
}
let mut sessions = self.0.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.phase != Phase::Created {
return Err(error("Recording already configured"));
}
session.bar_controlled = true;
Ok(())
}
}
fn owns_session(sender: &str, path: &str) -> bool {
let Some(sender) = sender.strip_prefix(':') else {
return false;
};
path.starts_with(&format!("{PATH}/session/{}/", sender.replace('.', "_")))
}
fn shell_recorder(pid: u32) -> bool {
use std::os::unix::fs::MetadataExt;
let matches_binary = |pid: u32, name: &str| -> Option<bool> {
let expected = std::env::current_exe().ok()?.parent()?.join(name);
let expected = std::fs::metadata(expected).ok()?;
let actual = std::fs::metadata(format!("/proc/{pid}/exe")).ok()?;
Some(expected.dev() == actual.dev() && expected.ino() == actual.ino())
};
if matches_binary(pid, "ferese-record") != Some(true) {
return false;
}
let parent = std::fs::read_to_string(format!("/proc/{pid}/status"))
.ok()
.and_then(|status| {
status.lines().find_map(|line| {
line.strip_prefix("PPid:")
.and_then(|s| s.trim().parse::<u32>().ok())
})
});
parent.is_some_and(|pid| matches_binary(pid, "ferese-shell") == Some(true))
}
async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> { async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> {
let sender = header let sender = header
.sender() .sender()
@@ -199,6 +267,7 @@ impl Backend {
phase: Phase::Created, phase: Phase::Created,
multiple: false, multiple: false,
cursor: false, cursor: false,
bar_controlled: false,
cancel: Arc::default(), cancel: Arc::default(),
}, },
); );
@@ -257,7 +326,7 @@ impl Backend {
{ {
return Err(error("Invalid request path")); return Err(error("Invalid request path"));
} }
let (multiple, cursor, cancel) = { let (multiple, cursor, bar_controlled, cancel) = {
let mut sessions = self.sessions.lock().await; let mut sessions = self.sessions.lock().await;
let session = sessions let session = sessions
.get_mut(session_handle.as_str()) .get_mut(session_handle.as_str())
@@ -266,7 +335,12 @@ impl Backend {
return Err(error("Select sources before starting a session")); return Err(error("Select sources before starting a session"));
} }
session.phase = Phase::Starting; session.phase = Phase::Starting;
(session.multiple, session.cursor, session.cancel.clone()) (
session.multiple,
session.cursor,
session.bar_controlled,
session.cancel.clone(),
)
}; };
let inserted = connection let inserted = connection
.object_server() .object_server()
@@ -290,7 +364,7 @@ impl Backend {
} }
let result = tokio::select! { let result = tokio::select! {
_ = cancel.wait() => Err(StartError::Cancelled), _ = cancel.wait() => Err(StartError::Cancelled),
result = tokio::time::timeout(Duration::from_secs(120), start_streams(&app_id, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))), result = tokio::time::timeout(Duration::from_secs(120), start_streams(if bar_controlled { "Ferese" } else { &app_id }, multiple, cursor, !bar_controlled)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
}; };
let _ = connection let _ = connection
.object_server() .object_server()
@@ -414,7 +488,8 @@ async fn start_streams(
app: &str, app: &str,
multiple: bool, multiple: bool,
cursor: bool, cursor: bool,
) -> Result<(Vec<Child>, Child, Vec<Ready>), StartError> { show_indicator: bool,
) -> Result<(Vec<Child>, Option<Child>, Vec<Ready>), StartError> {
let sources = tokio::task::spawn_blocking(|| { let sources = tokio::task::spawn_blocking(|| {
Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources()) Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources())
}) })
@@ -469,13 +544,19 @@ async fn start_streams(
streams.push(ready); streams.push(ready);
children.push(child); children.push(child);
} }
let indicator = picker(&Prompt { let indicator = if show_indicator {
Some(
picker(&Prompt {
app: app.to_owned(), app: app.to_owned(),
sources: selected, sources: selected,
multiple, multiple,
indicator: true, indicator: true,
}) })
.await?; .await?,
)
} else {
None
};
Ok((children, indicator, streams)) Ok((children, indicator, streams))
} }
fn validate_selection( fn validate_selection(
@@ -501,10 +582,12 @@ fn validate_selection(
} }
Ok(selected) Ok(selected)
} }
async fn supervise(mut children: Vec<Child>, mut indicator: Child, cancel: Arc<Cancel>) { async fn supervise(mut children: Vec<Child>, mut indicator: Option<Child>, cancel: Arc<Cancel>) {
loop { loop {
if cancel.stopped.load(Ordering::SeqCst) if cancel.stopped.load(Ordering::SeqCst)
|| !matches!(indicator.try_wait(), Ok(None)) || indicator
.as_mut()
.is_some_and(|child| !matches!(child.try_wait(), Ok(None)))
|| children || children
.iter_mut() .iter_mut()
.any(|c| !matches!(c.try_wait(), Ok(None))) .any(|c| !matches!(c.try_wait(), Ok(None)))
@@ -517,13 +600,19 @@ async fn supervise(mut children: Vec<Child>, mut indicator: Child, cancel: Arc<C
for child in &mut children { for child in &mut children {
let _ = child.kill().await; let _ = child.kill().await;
} }
if let Some(indicator) = &mut indicator {
let _ = indicator.kill().await; let _ = indicator.kill().await;
}
} }
pub async fn run() -> Result<(), Box<dyn std::error::Error>> { pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let backend = Backend::default(); let backend = Backend::default();
let connection = zbus::connection::Builder::session()? let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")? .name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())? .serve_at(PATH, backend.clone())?
.serve_at(
"/org/ferese/ScreenRecorder",
RecorderControl(backend.clone()),
)?
.build() .build()
.await?; .await?;
// Frontend death must revoke every stream, even if Session.Close never arrives. // Frontend death must revoke every stream, even if Session.Close never arrives.
@@ -557,6 +646,22 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn bar_control_claims_are_bound_to_the_recorder_connection() {
assert!(owns_session(
":1.42",
"/org/freedesktop/portal/desktop/session/1_42/record"
));
assert!(!owns_session(
":1.4",
"/org/freedesktop/portal/desktop/session/1_42/record"
));
assert!(!owns_session(
":1.43",
"/org/freedesktop/portal/desktop/session/1_42/record"
));
assert!(!shell_recorder(std::process::id()));
}
#[test]
fn rejects_unimplemented_sources_and_cursor_modes() { fn rejects_unimplemented_sources_and_cursor_modes() {
assert_eq!(source_options(&Options::new()).unwrap(), (false, false)); assert_eq!(source_options(&Options::new()).unwrap(), (false, false));
for bits in [0u32, 2, 3, 4] { for bits in [0u32, 2, 3, 4] {
+61 -5
View File
@@ -27,6 +27,8 @@ struct Picker {
core: Core, core: Core,
prompt: Prompt, prompt: Prompt,
selected: Vec<usize>, selected: Vec<usize>,
background: cosmic::iced::Color,
foreground: cosmic::iced::Color,
} }
pub fn run() -> Result<(), Box<dyn std::error::Error>> { pub fn run() -> Result<(), Box<dyn std::error::Error>> {
@@ -40,7 +42,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
return Err("No shareable displays".into()); return Err("No shareable displays".into());
} }
let (theme, font) = appearance(); let (theme, font, background, foreground) = appearance();
let height = 156. + (prompt.sources.len().min(4) as f32 * 56.); let height = 156. + (prompt.sources.len().min(4) as f32 * 56.);
cosmic::app::run::<Picker>( cosmic::app::run::<Picker>(
Settings::default() Settings::default()
@@ -49,7 +51,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
.default_font(font) .default_font(font)
.default_text_size(14.) .default_text_size(14.)
.is_daemon(false), .is_daemon(false),
prompt, (prompt, background, foreground),
)?; )?;
Ok(()) Ok(())
@@ -57,7 +59,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
impl cosmic::Application for Picker { impl cosmic::Application for Picker {
type Executor = cosmic::executor::Default; type Executor = cosmic::executor::Default;
type Flags = Prompt; type Flags = (Prompt, cosmic::iced::Color, cosmic::iced::Color);
type Message = Message; type Message = Message;
const APP_ID: &'static str = "dev.ferese.ScreenShare"; const APP_ID: &'static str = "dev.ferese.ScreenShare";
@@ -69,14 +71,20 @@ impl cosmic::Application for Picker {
&mut self.core &mut self.core
} }
fn init(mut core: Core, prompt: Prompt) -> (Self, Task<Message>) { fn init(
mut core: Core,
(prompt, background, foreground): Self::Flags,
) -> (Self, Task<Message>) {
core.window.show_headerbar = false; core.window.show_headerbar = false;
core.window.border_padding = Some(0); core.window.border_padding = Some(0);
core.window.content_container = false;
// No preselected source: clicking Share must be a deliberate choice. // No preselected source: clicking Share must be a deliberate choice.
let mut app = Self { let mut app = Self {
core, core,
prompt, prompt,
selected: Vec::new(), selected: Vec::new(),
background,
foreground,
}; };
let title = if app.prompt.indicator { let title = if app.prompt.indicator {
"Ferese — screen sharing" "Ferese — screen sharing"
@@ -122,6 +130,14 @@ impl cosmic::Application for Picker {
Task::none() Task::none()
} }
fn style(&self) -> Option<cosmic::iced::theme::Style> {
Some(cosmic::iced::theme::Style {
background_color: self.background,
text_color: self.foreground,
icon_color: self.foreground,
})
}
fn view(&self) -> Element<'_, Message> { fn view(&self) -> Element<'_, Message> {
let app = if self.prompt.app.is_empty() { let app = if self.prompt.app.is_empty() {
"this application" "this application"
@@ -234,7 +250,12 @@ fn glyph(source: &'static [u8], size: u16) -> icon::Icon {
.size(size) .size(size)
} }
fn appearance() -> (cosmic::Theme, cosmic::font::Font) { fn appearance() -> (
cosmic::Theme,
cosmic::font::Font,
cosmic::iced::Color,
cosmic::iced::Color,
) {
let document = ferese_config::config_path() let document = ferese_config::config_path()
.and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| ferese_config::Document::parse(&s).ok()); .and_then(|s| ferese_config::Document::parse(&s).ok());
@@ -296,6 +317,15 @@ fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
.corner_radii(corners) .corner_radii(corners)
.build(); .build();
ferese_theme::apply(&mut native, foreground.into()); ferese_theme::apply(&mut native, foreground.into());
let mut material: cosmic::iced::Color = background.into();
material.a = material_opacity(
&string("theme.material.style", "solid"),
document
.as_ref()
.and_then(|d| d.get("theme.surface.popover.opacity"))
.and_then(|v| v.as_f64())
.unwrap_or(0.96),
);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native)); let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
let family: &'static str = let family: &'static str =
Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str()); Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str());
@@ -306,5 +336,31 @@ fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
family: cosmic::iced::font::Family::Name(family), family: cosmic::iced::font::Family::Name(family),
..cosmic::font::default() ..cosmic::font::default()
}, },
material,
cosmic::iced::Color {
a: 1.,
..foreground.into()
},
) )
} }
// Only the surface tint is translucent; control fills and foregrounds stay opaque.
fn material_opacity(style: &str, opacity: f64) -> f32 {
if style == "translucent" && opacity.is_finite() {
opacity.clamp(0., 1.) as f32
} else {
1.
}
}
#[cfg(test)]
mod tests {
#[test]
fn material_preserves_solid_and_bounds_translucent_opacity() {
assert_eq!(super::material_opacity("solid", 0.4), 1.);
assert_eq!(super::material_opacity("translucent", 0.4), 0.4);
assert_eq!(super::material_opacity("translucent", 2.), 1.);
assert_eq!(super::material_opacity("translucent", -1.), 0.);
assert_eq!(super::material_opacity("translucent", f64::NAN), 1.);
}
}
+15
View File
@@ -136,6 +136,21 @@ pub async fn run() -> Result<(), String> {
.map_err(|e| e.to_string())?, .map_err(|e| e.to_string())?,
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Older/foreign backends keep their normal sharing indicator. Only Ferese
// can verify this helper's identity and that its parent owns a bar stop button.
let _ = tokio::time::timeout(Duration::from_secs(3), async {
let control = Proxy::new(
&connection,
"org.freedesktop.impl.portal.desktop.ferese",
"/org/ferese/ScreenRecorder",
"org.ferese.ScreenRecorder",
)
.await?;
control
.call::<_, _, ()>("UseBarControls", &(handle.clone(),))
.await
})
.await;
let session = Proxy::new( let session = Proxy::new(
&connection, &connection,
DESKTOP, DESKTOP,
+6
View File
@@ -126,6 +126,12 @@ class PortalTest(unittest.TestCase):
None, Gio.DBusCallFlags.NONE, 5000, None) None, Gio.DBusCallFlags.NONE, 5000, None)
def create(token): def create(token):
session = request("CreateSession", GLib.Variant("(a{sv})", ({"session_handle_token": GLib.Variant("s", token)},)))["session_handle"] session = request("CreateSession", GLib.Variant("(a{sv})", ({"session_handle_token": GLib.Variant("s", token)},)))["session_handle"]
# An ordinary portal client cannot suppress its stop indicator.
with self.assertRaises(GLib.Error) as denied:
connection.call_sync("org.freedesktop.impl.portal.desktop.ferese", "/org/ferese/ScreenRecorder",
"org.ferese.ScreenRecorder", "UseBarControls", GLib.Variant("(o)", (session,)),
None, Gio.DBusCallFlags.NONE, 5000, None)
self.assertIn("AccessDenied", str(denied.exception))
request("SelectSources", GLib.Variant("(oa{sv})", (session, {"types": GLib.Variant("u", 1)}))) request("SelectSources", GLib.Variant("(oa{sv})", (session, {"types": GLib.Variant("u", 1)})))
return session return session
def helpers(): def helpers():
+3 -3
View File
@@ -146,10 +146,10 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
} else { } else {
p.card p.card
}; };
let on = if selected { let (background, on) = if selected {
ferese_theme::foreground(background, p.text) ferese_theme::accent_pair(background, p.text)
} else { } else {
p.text (background, p.text)
}; };
button::Style { button::Style {
background: Some(Background::Color(background)), background: Some(Background::Color(background)),
+2 -1
View File
@@ -341,7 +341,8 @@ impl FereseShell {
palette.surface_popover palette.surface_popover
}), }),
); );
let badge_text = ferese_theme::foreground(badge_fill, color(palette.text_primary)); let (badge_fill, badge_text) =
ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let badge = container( let badge = container(
text(count.to_string()) text(count.to_string())
.size(10) .size(10)