Add configurable capture and OBS status to the panel
This commit is contained in:
1 parent
3d08d64c41
commit
e03d491353
22 files changed
+1692
-67
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user