Add configurable capture and OBS status to the panel

This commit is contained in:
Samuel Onoja committed 2026-10-25 16:35:51 +01:00
1 parent 3d08d64c41
commit e03d491353
22 files changed
+1692 -67

No files matched your search

+94 -29
View File
@@ -60,11 +60,39 @@ impl FereseShell {
} else {
theme.text_muted
});
let indicator = if kind == Menu::Battery {
let mut indicator = if kind == Menu::Battery {
self.battery_indicator(theme, 1.0, battery_percentage)
} else {
accented_icon(source, metrics.icon_size, foreground, color(theme.accent)).into()
};
if kind == Menu::Notifications {
let unread = self
.status
.notifications
.as_ref()
.is_some_and(|n| n.count > 0 && !n.dnd);
let dot = container(cosmic::widget::Space::new())
.width(4)
.height(4)
.class(theme::Container::custom(move |_| container::Style {
background: unread.then_some(Background::Color(foreground)),
border: Border {
shape: BorderShape::Continuous,
radius: self.render.radius(2.0).into(),
..Default::default()
},
..Default::default()
}));
// Keep the popup anchor stable when reading clears the badge.
indicator = cosmic::iced::widget::stack![
indicator,
container(dot)
.width(metrics.icon_size)
.height(metrics.icon_size)
.align_x(Alignment::End),
]
.into();
}
let mut content = row![indicator].spacing(4).align_y(Alignment::Center);
if kind == Menu::Recording
@@ -77,32 +105,13 @@ impl FereseShell {
.class(theme::Text::Color(foreground)),
);
}
if kind == Menu::Notifications {
// Reading notifications clears the dot. Keep its space so the
// resulting panel allocation does not dismiss the new popover.
let unread = self
.status
.notifications
.as_ref()
.is_some_and(|n| n.count > 0 && !n.dnd);
content = content.push(
container(cosmic::widget::Space::new())
.width(4)
.height(4)
.class(theme::Container::custom(move |_| container::Style {
background: unread.then_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();
let icon_width = if kind == Menu::Battery {
crate::battery_indicator::WIDTH
} else {
f32::from(metrics.icon_size)
};
let control = button::custom(content)
.name(if recording_busy {
self.recorder.label().into()
@@ -111,10 +120,7 @@ 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 - icon_width) * 0.5).max(4.0)])
.height(metrics.group_item_height)
.on_press_with_rectangle(move |offset, bounds| {
if kind == Menu::Recording {
@@ -165,6 +171,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
Menu::Calendar => "Calendar".into(),
Menu::Overflow => "More panel items".into(),
Menu::Recording => "Screen recording".into(),
Menu::CaptureStatus => "Capture status".into(),
Menu::Media => "Now playing".into(),
Menu::Battery => s.battery.as_ref().map_or_else(
|| "Battery: unavailable".into(),
@@ -226,6 +233,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
Menu::Calendar => (ferese_theme::icons::CALENDAR, true),
Menu::Overflow => (ferese_theme::icons::CHEVRON_DOWN, true),
Menu::Recording => (ferese_theme::icons::RECORD, true),
Menu::CaptureStatus => (ferese_theme::icons::DISPLAY, true),
Menu::Media => (ferese_theme::icons::MEDIA, true),
Menu::Battery => {
let Some(b) = &s.battery else {
@@ -263,6 +271,63 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
mod tests {
use super::*;
use crate::status;
#[test]
fn status_hitboxes_stay_aligned_when_badges_and_battery_values_change() {
use cosmic::iced::Size;
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::advanced::{layout, widget};
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut shell = crate::tests::shell_with_measured_panel();
let measure = |shell: &FereseShell, kind, percentage| {
let mut view = shell.view_status_item(kind, percentage, false);
let mut tree = widget::Tree::new(&view);
view.as_widget_mut()
.layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1000., 1000.)),
)
.size()
};
for height in [28., 36., 48.] {
shell.config.panels[0].geometry.height = Some(height);
let ordinary = measure(&shell, Menu::System, false);
for (count, dnd) in [(0, false), (3, false), (0, false), (3, true)] {
shell.status.notifications = Some(status::Notifications { count, dnd });
assert_eq!(
measure(&shell, Menu::Notifications, false),
ordinary,
"notification badges must stay inside the normal icon hitbox"
);
}
for percent in [0, 9, 50, 100] {
shell.status.battery = Some(status::Battery {
percent,
status: "Discharging".into(),
});
for percentage in [false, true] {
let battery = measure(&shell, Menu::Battery, percentage);
assert_eq!(battery.height, ordinary.height);
assert!(
battery.width <= ordinary.width.max(38.5),
"battery adds excess horizontal padding: {battery:?} vs {ordinary:?}"
);
assert!(
battery.width >= crate::battery_indicator::WIDTH + 8.,
"battery must retain space around the body and terminal"
);
}
}
}
}
#[test]
fn icons_follow_real_states() {
let mut s = Snapshot {
+104 -1
View File
@@ -48,6 +48,7 @@ pub enum Menu {
Battery,
Calendar,
Recording,
CaptureStatus,
Media,
Notifications,
System,
@@ -101,6 +102,7 @@ impl Menu {
Self::Battery => "Battery",
Self::Calendar => "Calendar",
Self::Recording => "Screen recording",
Self::CaptureStatus => "Capture status",
Self::Media => "Now playing",
Self::Notifications => "Notifications",
}
@@ -113,7 +115,7 @@ impl Menu {
Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(),
Self::Battery => status.battery.is_some(),
Self::Calendar | Self::Recording | Self::Media => true,
Self::Calendar | Self::Recording | Self::CaptureStatus | Self::Media => true,
Self::Notifications => status.notifications.is_some(),
Self::System | Self::Overflow | Self::Workspaces | Self::Activities => true,
}
@@ -300,6 +302,33 @@ impl FereseShell {
}
rows
}
Menu::CaptureStatus => {
for detail in self.capture_status.details() {
rows = rows.push(
self.render
.text(detail)
.size(13)
.class(theme::Text::Color(style.primary)),
);
}
if let Some(elapsed) = self.recorder.elapsed() {
rows = rows.push(
self.render
.text(format!("Ferese · REC {elapsed}"))
.size(13)
.class(theme::Text::Color(style.primary)),
);
}
if !self.capture_status.active() {
rows = rows.push(
self.render
.text("No active screen sharing or OBS output")
.size(13)
.class(theme::Text::Color(style.muted)),
);
}
rows
}
Menu::Recording => rows.push(menu_button(
self.render,
if self.recorder.busy() {
@@ -443,6 +472,7 @@ impl FereseShell {
}
Menu::Notifications => notification_controls(self, rows, style, false),
Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)),
Menu::CaptureStatus => rows.push(self.view_hub_menu(kind, p)),
Menu::Recording | Menu::Workspaces => rows,
Menu::Overflow => self.view_panel_overflow(rows),
Menu::Media => media::view(self, rows, style),
@@ -581,6 +611,79 @@ mod tests {
use super::*;
use std::time::Duration;
#[test]
fn capture_status_popover_shows_application_and_source() {
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::advanced::{Layout, layout, widget};
use cosmic::iced::{Rectangle, Size};
#[derive(Default)]
struct Labels(Vec<String>);
impl widget::Operation for Labels {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn text(&mut self, _: Option<&widget::Id>, _: Rectangle, text: &str) {
self.0.push(text.into());
}
}
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = crate::tests::shell_with_measured_panel();
app.capture_status
.apply(crate::capture_status::Event::Sharing(vec![
ferese_ipc::capture::Session {
app: "com.obsproject.Studio".into(),
sources: vec!["Workspace 2".into()],
},
]));
let bar = app.outputs[0].bar;
drop(app.open_menu(
Menu::CaptureStatus,
PopoverAnchor {
parent: bar,
panel: app.config.panels[0].id.clone(),
item: None,
rectangle: Rectangle {
x: 0,
y: 0,
width: 100,
height: 28,
},
},
));
for hub in [false, true] {
let mut view = if hub {
app.view_hub_menu(Menu::CaptureStatus, 1.)
} else {
app.view_status_menu_at(Instant::now() + Duration::from_secs(1))
};
let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(300., 500.)),
);
let mut labels = Labels::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
assert!(
labels.0.iter().any(|text| text == "OBS · Sharing"),
"{hub}: {:?}",
labels.0
);
assert!(
labels.0.iter().any(|text| text == "Workspace 2"),
"{hub}: {:?}",
labels.0
);
assert!(!labels.0.iter().any(|text| text.contains("LIVE")));
}
}
#[test]
fn activity_popups_scroll_to_the_last_row_in_regular_panels() {
use cosmic::iced::advanced::renderer::Headless;