4617 lines
180 KiB
Rust
4617 lines
180 KiB
Rust
use std::cell::RefCell;
|
|
use std::time::Instant;
|
|
|
|
use crate::KeyboardInteractivity;
|
|
use cosmic::app::Task;
|
|
use cosmic::iced::platform_specific::shell::commands::layer_surface::{
|
|
set_keyboard_interactivity, set_size,
|
|
};
|
|
use cosmic::iced::{Alignment, Length, window};
|
|
use cosmic::widget::{button, column, container, row, scrollable};
|
|
|
|
mod activity;
|
|
mod battery;
|
|
mod content_frame;
|
|
mod equalizer;
|
|
mod scroll;
|
|
mod transition;
|
|
use transition::{Event, Transient};
|
|
mod workspace_grid;
|
|
mod workspaces;
|
|
use cosmic::{Element, theme};
|
|
use ferese_panel::morph::{Content, Hub, Mode, Sample};
|
|
use ferese_panel::{Edge, ItemKind, Panel, PanelStyle};
|
|
use ferese_theme::icons;
|
|
|
|
use crate::{FereseShell, Message, color_with_opacity, motion, status_ui};
|
|
|
|
/// Morph owns its inset; regular-panel side margins and padding stay saved for
|
|
/// the other styles without reducing the hub's output budget.
|
|
fn available_size(panel: &Panel, size: Option<(i32, i32)>) -> [f64; 2] {
|
|
let (width, height) = size.unwrap_or((1280, 720));
|
|
[
|
|
f64::from(width).max(24.),
|
|
(f64::from(height) - f64::from(panel.resolved_geometry().edge_margin)).max(24.),
|
|
]
|
|
}
|
|
|
|
pub(super) fn content_width(panel: &Panel, size: Option<(i32, i32)>) -> f64 {
|
|
(available_size(panel, size)[0] - if panel.morph.attached { 24. } else { 0. }).max(24.)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Peek {
|
|
Recording,
|
|
Capture,
|
|
Battery,
|
|
Media,
|
|
Date,
|
|
Workspaces,
|
|
}
|
|
|
|
struct ControlAction {
|
|
icon: &'static [u8],
|
|
label: &'static str,
|
|
message: Option<Message>,
|
|
menu: Option<status_ui::Menu>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct PreviousContent {
|
|
menu: Option<status_ui::Menu>,
|
|
peek: Peek,
|
|
toast: Option<u32>,
|
|
size: [f64; 2],
|
|
}
|
|
|
|
pub(super) struct Runtime {
|
|
pub(super) hub: Hub,
|
|
pub(super) menu: Option<status_ui::Menu>,
|
|
previous: PreviousContent,
|
|
transient: Transient,
|
|
pub(super) allocation: [u32; 2],
|
|
minimum_allocation: [u32; 2],
|
|
fillet_space: u32,
|
|
pub(super) revision: Instant,
|
|
sampled: Instant,
|
|
motion: motion::Settings,
|
|
last_menu: status_ui::Menu,
|
|
priority: Option<Peek>,
|
|
peek: Peek,
|
|
observed_workspace: Option<u64>,
|
|
workspace_hover: Option<u64>,
|
|
workspace_selected: Option<u64>,
|
|
workspace_selection_pending: Option<u64>,
|
|
hovered: bool,
|
|
pub(crate) idle_content_width: Option<f64>,
|
|
available: [f64; 2],
|
|
peek_width: f64,
|
|
idle_width: f64,
|
|
workspace_width: f64,
|
|
workspace_scroll: workspaces::Scroll,
|
|
pub(super) activities: crate::activities::Presentation,
|
|
}
|
|
|
|
impl Runtime {
|
|
pub(super) fn new(panel: &Panel) -> Self {
|
|
let mut hub = Hub::try_new(panel.morph, panel.resolved_geometry().height.into())
|
|
.expect("validated panel hub");
|
|
hub.pin_radii(panel.corner_radius.map(|r| r.0.map(f64::from)))
|
|
.expect("validated panel radii");
|
|
hub.advance(std::time::Duration::ZERO, true);
|
|
let now = Instant::now();
|
|
// Keep the native surface stable through hover, including spring overshoot.
|
|
let fillet_space = if panel.morph.attached { 24 } else { 0 };
|
|
let minimum_allocation = [
|
|
(panel.morph.peek_width.max(panel.morph.idle_width).max(200.) * 1.1).ceil() as u32
|
|
+ fillet_space,
|
|
(panel
|
|
.morph
|
|
.peek_height
|
|
.max(f64::from(panel.resolved_geometry().height))
|
|
* 1.1)
|
|
.ceil() as u32,
|
|
];
|
|
Self {
|
|
allocation: minimum_allocation,
|
|
minimum_allocation,
|
|
fillet_space,
|
|
hub,
|
|
menu: None,
|
|
previous: PreviousContent {
|
|
menu: None,
|
|
peek: Peek::Date,
|
|
toast: None,
|
|
size: [
|
|
panel.morph.idle_width,
|
|
f64::from(panel.resolved_geometry().height),
|
|
],
|
|
},
|
|
transient: Transient::None,
|
|
revision: now,
|
|
sampled: now,
|
|
motion: Default::default(),
|
|
last_menu: status_ui::Menu::Workspaces,
|
|
priority: None,
|
|
peek: Peek::Date,
|
|
observed_workspace: None,
|
|
workspace_hover: None,
|
|
workspace_selected: None,
|
|
workspace_selection_pending: None,
|
|
hovered: false,
|
|
idle_content_width: None,
|
|
available: [1280., 720.],
|
|
peek_width: 200.,
|
|
idle_width: panel.morph.idle_width,
|
|
workspace_width: 0.,
|
|
workspace_scroll: Default::default(),
|
|
activities: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn sample(&mut self, now: Instant) -> Sample {
|
|
let policy = self.motion;
|
|
self.activities.advance(
|
|
now.saturating_duration_since(self.sampled),
|
|
!policy.motion_enabled(),
|
|
);
|
|
self.hub.advance(
|
|
now.saturating_duration_since(self.sampled)
|
|
.mul_f64(policy.speed),
|
|
!policy.motion_enabled(),
|
|
);
|
|
self.sampled = self.sampled.max(now);
|
|
self.hub.sample()
|
|
}
|
|
|
|
pub(crate) fn refresh_activity_size(&mut self) {
|
|
if self.activities.selection.visible.is_empty() {
|
|
return;
|
|
}
|
|
if self.hub.mode() == Mode::Idle {
|
|
let width = self
|
|
.idle_content_width
|
|
.map_or(self.idle_width + self.workspace_width, |width| {
|
|
width + self.workspace_width
|
|
})
|
|
.clamp(24., 1024.);
|
|
let _ = self.hub.retarget_idle(width, self.available);
|
|
} else if self.hub.mode() == Mode::Peek
|
|
&& self.peek != Peek::Workspaces
|
|
&& self.priority.is_none()
|
|
&& let Some(width) = self.idle_content_width
|
|
{
|
|
self.peek_width = width + self.workspace_width;
|
|
let _ = self
|
|
.hub
|
|
.retarget_peek(self.peek_width.clamp(24., 1024.), self.available);
|
|
}
|
|
}
|
|
|
|
fn retarget(&mut self, mode: Mode, available: [f64; 2], menu: Option<status_ui::Menu>) {
|
|
let now = Instant::now();
|
|
let shape_available = [
|
|
(available[0] - f64::from(self.fillet_space)).max(24.),
|
|
available[1],
|
|
];
|
|
self.available = shape_available;
|
|
self.sample(now);
|
|
self.menu = menu;
|
|
if mode == Mode::Idle {
|
|
self.hub.retarget_idle(
|
|
self.idle_content_width
|
|
.map(|width| {
|
|
if self.activities.selection.visible.is_empty() {
|
|
width
|
|
} else {
|
|
width + self.workspace_width
|
|
}
|
|
})
|
|
.unwrap_or(self.idle_width + self.workspace_width)
|
|
.clamp(24., 1024.),
|
|
shape_available,
|
|
)
|
|
} else if mode == Mode::Peek {
|
|
self.hub
|
|
.retarget_peek(self.peek_width.min(1024.), shape_available)
|
|
} else {
|
|
self.hub.retarget(mode, shape_available)
|
|
}
|
|
.expect("bounded hub area");
|
|
let mut target = self.hub.allocation_size().map(|n| n.ceil() as u32);
|
|
target[0] = target[0]
|
|
.saturating_add(self.fillet_space)
|
|
.min(available[0].ceil() as u32);
|
|
// Retain the backing surface between opens; resizing after settlement moves its origin.
|
|
self.minimum_allocation =
|
|
std::array::from_fn(|i| self.minimum_allocation[i].min(available[i].ceil() as u32));
|
|
self.allocation = std::array::from_fn(|i| {
|
|
self.allocation[i]
|
|
.max(target[i])
|
|
.max(self.minimum_allocation[i])
|
|
.min(available[i].ceil() as u32)
|
|
});
|
|
self.revision = now;
|
|
}
|
|
}
|
|
|
|
pub(super) fn new(panel: &Panel) -> Option<RefCell<Runtime>> {
|
|
(panel.style == PanelStyle::Morph).then(|| RefCell::new(Runtime::new(panel)))
|
|
}
|
|
|
|
fn content(kind: status_ui::Menu) -> Content {
|
|
match kind {
|
|
status_ui::Menu::Activities => Content::Activities,
|
|
status_ui::Menu::Workspaces => Content::Workspaces,
|
|
status_ui::Menu::Media => Content::Media,
|
|
status_ui::Menu::Calendar => Content::Calendar,
|
|
status_ui::Menu::Notifications => Content::Notifications,
|
|
_ => Content::Controls,
|
|
}
|
|
}
|
|
|
|
impl FereseShell {
|
|
pub(super) fn hub_mode(&self, id: window::Id) -> Option<Mode> {
|
|
self.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)?
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.map(|hub| hub.borrow().hub.mode())
|
|
}
|
|
|
|
pub(super) fn hub_hover(&mut self, id: window::Id, entered: bool) -> Task<Message> {
|
|
if self.bar_hidden(id) {
|
|
return Task::none();
|
|
}
|
|
if let Some(runtime) = self
|
|
.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.runtime.hub.as_ref())
|
|
{
|
|
let mut runtime = runtime.borrow_mut();
|
|
if entered {
|
|
runtime.activities.selection.enter();
|
|
} else {
|
|
runtime.activities.selection.leave();
|
|
}
|
|
}
|
|
self.sync_activity_presentations();
|
|
self.transition_hub(
|
|
id,
|
|
Event::Hover {
|
|
entered,
|
|
enabled: self.panel_for_bar(id).morph.peek_on_hover,
|
|
peek: self.hub_peek_content(id),
|
|
priority: self.hub_priority_for(id),
|
|
},
|
|
)
|
|
}
|
|
|
|
pub(super) fn open_hub(&mut self, id: window::Id, kind: status_ui::Menu) -> Task<Message> {
|
|
if self.snapshot.binding_mode.is_some()
|
|
|| self.bar_hidden(id)
|
|
|| self.hub_mode(id).is_none()
|
|
{
|
|
return Task::none();
|
|
}
|
|
if kind == status_ui::Menu::Notifications {
|
|
for notice in &mut self.notifications.entries {
|
|
notice.unread = false;
|
|
}
|
|
if let Some(status) = &mut self.status.notifications {
|
|
status.count = 0;
|
|
}
|
|
self.sync_activity_presentations();
|
|
}
|
|
if kind == status_ui::Menu::Calendar {
|
|
self.calendar_offset = 0;
|
|
}
|
|
if kind == status_ui::Menu::Media {
|
|
self.media.now_us = ferese_ipc::media::now_us();
|
|
self.media.refresh_art(true);
|
|
self.media.error = None;
|
|
}
|
|
let close = self.destroy_menu();
|
|
let open = self.transition_hub(id, Event::Open(kind));
|
|
Task::batch([close, open])
|
|
}
|
|
|
|
pub(super) fn suspend_hub(&mut self, id: window::Id) -> Task<Message> {
|
|
let close = self.close_hub(id);
|
|
let Some(hub) = self
|
|
.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.runtime.hub.as_ref())
|
|
else {
|
|
return close;
|
|
};
|
|
let mut hub = hub.borrow_mut();
|
|
hub.hub.advance(std::time::Duration::ZERO, true);
|
|
hub.allocation = hub.minimum_allocation;
|
|
Task::batch([
|
|
close,
|
|
set_size(id, Some(hub.allocation[0]), Some(hub.allocation[1])),
|
|
])
|
|
}
|
|
|
|
pub(super) fn hub_notice(&mut self, notice: u32) -> Task<Message> {
|
|
let Some(id) = self.active_bar() else {
|
|
return Task::none();
|
|
};
|
|
if self.snapshot.binding_mode.is_some()
|
|
|| self.bar_hidden(id)
|
|
|| self.hub_priority_for(id).is_some()
|
|
|| !matches!(
|
|
self.hub_mode(id),
|
|
Some(Mode::Idle | Mode::Peek | Mode::Toast)
|
|
)
|
|
|| !self
|
|
.notifications
|
|
.popup_groups()
|
|
.iter()
|
|
.any(|(entry, _, _)| entry.id == notice)
|
|
{
|
|
return Task::none();
|
|
}
|
|
let revision = Instant::now();
|
|
let open = self.transition_hub(
|
|
id,
|
|
Event::Toast {
|
|
notice,
|
|
token: revision,
|
|
},
|
|
);
|
|
let timeout =
|
|
std::time::Duration::from_millis(u64::from(self.config.notifications.timeout_ms));
|
|
Task::batch([
|
|
open,
|
|
Task::perform(
|
|
async move {
|
|
tokio::time::sleep(timeout).await;
|
|
},
|
|
move |_| cosmic::Action::App(Message::HubToastExpired(id, revision)),
|
|
),
|
|
])
|
|
}
|
|
|
|
pub(super) fn sync_hub_notices(&mut self) -> Task<Message> {
|
|
let closing: Vec<_> = self
|
|
.outputs
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
let hub = entry.runtime.hub.as_ref()?.borrow();
|
|
(hub.hub.mode() == Mode::Toast
|
|
&& hub.transient.toast().is_some_and(|id| {
|
|
!self
|
|
.notifications
|
|
.entries
|
|
.iter()
|
|
.any(|notice| notice.id == id && notice.live)
|
|
}))
|
|
.then_some(entry.bar)
|
|
})
|
|
.collect();
|
|
Task::batch(closing.into_iter().map(|id| self.close_hub(id)))
|
|
}
|
|
|
|
pub(super) fn hub_toast_expired(&mut self, id: window::Id, revision: Instant) -> Task<Message> {
|
|
self.transition_hub(id, Event::ToastExpired(revision))
|
|
}
|
|
|
|
pub(super) fn close_hub(&mut self, id: window::Id) -> Task<Message> {
|
|
self.night_light_temperature = None;
|
|
self.transition_hub(id, Event::Close)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn set_hub_mode(
|
|
&mut self,
|
|
id: window::Id,
|
|
mode: Mode,
|
|
menu: Option<status_ui::Menu>,
|
|
) -> Task<Message> {
|
|
self.transition_hub(id, Event::Present(mode, menu))
|
|
}
|
|
|
|
pub(super) fn resize_hub(&mut self, id: window::Id) -> Task<Message> {
|
|
self.transition_hub(id, Event::Resize)
|
|
}
|
|
|
|
fn transition_hub(&mut self, id: window::Id, event: Event) -> Task<Message> {
|
|
if self.snapshot.binding_mode.is_some() && !matches!(event, Event::Close | Event::Resize) {
|
|
return Task::none();
|
|
}
|
|
let workspace_width = self.hub_workspace_width(id);
|
|
let panel = self.panel_for_bar(id);
|
|
let Some(entry) = self.outputs.iter().find(|entry| entry.bar == id) else {
|
|
return Task::none();
|
|
};
|
|
let Some(hub) = &entry.runtime.hub else {
|
|
return Task::none();
|
|
};
|
|
let available = available_size(panel, entry.size);
|
|
let (resize, mode) = {
|
|
let mut hub = hub.borrow_mut();
|
|
hub.motion = self.render.motion;
|
|
let before = hub.allocation;
|
|
hub.workspace_width = if workspace_width > 0. {
|
|
workspace_width + 10.
|
|
} else {
|
|
0.
|
|
};
|
|
if !hub.transition(event, available) {
|
|
return Task::none();
|
|
}
|
|
(
|
|
(before != hub.allocation).then_some(hub.allocation),
|
|
hub.hub.mode(),
|
|
)
|
|
};
|
|
Task::batch([
|
|
resize.map_or_else(Task::none, |size| {
|
|
set_size(id, Some(size[0]), Some(size[1]))
|
|
}),
|
|
set_keyboard_interactivity(
|
|
id,
|
|
if mode.expanded() {
|
|
KeyboardInteractivity::OnDemand
|
|
} else {
|
|
KeyboardInteractivity::None
|
|
},
|
|
),
|
|
self.dismiss_workspace_tooltip_for_bar(id),
|
|
])
|
|
}
|
|
|
|
fn hub_idle_width_limit(&self, id: window::Id) -> f32 {
|
|
let runtime = self
|
|
.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.runtime.hub.as_ref())
|
|
.expect("Morph runtime");
|
|
let runtime = runtime.borrow();
|
|
if !runtime.activities.selection.visible.is_empty()
|
|
&& self.compact_activities_configured(id)
|
|
{
|
|
return runtime.hub.target_size()[0].min(runtime.available[0]) as f32;
|
|
}
|
|
let artwork_width = if self.hub_priority_for(id).is_some() {
|
|
64.
|
|
} else if self
|
|
.media
|
|
.snapshot
|
|
.selected
|
|
.as_ref()
|
|
.is_some_and(|player| player.status == ferese_ipc::media::Playback::Playing)
|
|
{
|
|
32.
|
|
} else {
|
|
0.
|
|
};
|
|
(runtime.idle_width + artwork_width + runtime.workspace_width)
|
|
.min(runtime.available[0])
|
|
.min(f64::from(runtime.allocation[0].saturating_sub(runtime.fillet_space)) / 1.1)
|
|
as f32
|
|
}
|
|
|
|
pub(super) fn view_hub(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
|
// The toolkit may request one last frame while output destruction is queued.
|
|
let Some(entry) = self.outputs.iter().find(|entry| entry.bar == id) else {
|
|
return cosmic::widget::Space::new().into();
|
|
};
|
|
let runtime = entry.runtime.hub.as_ref().expect("Morph runtime");
|
|
runtime.borrow_mut().motion = self.render.motion;
|
|
let revision = runtime.borrow().revision;
|
|
let mode = runtime.borrow().hub.mode();
|
|
let workspace_feedback = mode == Mode::Peek && runtime.borrow().peek == Peek::Workspaces;
|
|
let measurement =
|
|
if (mode == Mode::Idle && !self.has_compact_activities(id)) || workspace_feedback {
|
|
let cap = if workspace_feedback {
|
|
let runtime = runtime.borrow();
|
|
260_f64.min(runtime.available[0]).min(
|
|
f64::from(runtime.allocation[0].saturating_sub(runtime.fillet_space)) / 1.1,
|
|
) as f32
|
|
} else {
|
|
self.hub_idle_width_limit(id)
|
|
};
|
|
Some(motion::ContentMeasurement {
|
|
content: self.hub_content(id, mode, 1., None, false, true),
|
|
width: cap,
|
|
measured: Box::new(move |size| {
|
|
let mut runtime = runtime.borrow_mut();
|
|
let width = f64::from(size.width).ceil().clamp(24., 1024.);
|
|
let available = runtime.available;
|
|
if workspace_feedback {
|
|
runtime.peek_width = width;
|
|
runtime
|
|
.hub
|
|
.retarget_peek(width, available)
|
|
.expect("measured workspace feedback");
|
|
} else {
|
|
runtime.idle_content_width = Some(width);
|
|
runtime
|
|
.hub
|
|
.retarget_idle(width, available)
|
|
.expect("measured idle content");
|
|
}
|
|
}),
|
|
})
|
|
} else {
|
|
(runtime.borrow().hub.mode() == Mode::Expanded(Content::Controls)).then(|| {
|
|
let menu = runtime.borrow().menu.unwrap_or(status_ui::Menu::Overflow);
|
|
let content = if menu == status_ui::Menu::Overflow {
|
|
self.hub_controls(id, 1.)
|
|
} else {
|
|
self.view_hub_menu(menu, 1.)
|
|
};
|
|
let width = runtime.borrow().hub.target_size()[0] as f32;
|
|
motion::ContentMeasurement {
|
|
content,
|
|
width: (width - 28.).max(1.),
|
|
measured: Box::new(move |size| {
|
|
runtime
|
|
.borrow_mut()
|
|
.hub
|
|
.fit_content_height(f64::from(size.height).ceil() + 56.)
|
|
.expect("measured hub content");
|
|
}),
|
|
}
|
|
})
|
|
};
|
|
motion::geometry_driven(
|
|
Some(revision),
|
|
move |now| {
|
|
let (sample, menu, previous_menu) = {
|
|
let mut runtime = runtime.borrow_mut();
|
|
(runtime.sample(now), runtime.menu, runtime.previous.menu)
|
|
};
|
|
self.view_hub_at(id, sample, menu, previous_menu)
|
|
},
|
|
move |_| {
|
|
let runtime = runtime.borrow();
|
|
runtime.hub.active() || runtime.activities.active()
|
|
},
|
|
|_| {},
|
|
|_| None,
|
|
measurement,
|
|
)
|
|
}
|
|
|
|
fn view_hub_at(
|
|
&self,
|
|
id: window::Id,
|
|
sample: Sample,
|
|
menu: Option<status_ui::Menu>,
|
|
previous_menu: Option<status_ui::Menu>,
|
|
) -> Element<'_, cosmic::Action<Message>> {
|
|
let Some(entry) = self.outputs.iter().find(|entry| entry.bar == id) else {
|
|
return cosmic::widget::Space::new().into();
|
|
};
|
|
let panel = self.panel_for_bar(id);
|
|
let effects = entry.effects.as_ref();
|
|
let bounds = sample.geometry.bounds;
|
|
let grid = ferese_panel::shape::PixelGrid::try_new(entry.scale)
|
|
.expect("panel output scale is positive and finite");
|
|
let (target_size, previous_size) = {
|
|
let runtime = entry.runtime.hub.as_ref().expect("Morph runtime").borrow();
|
|
(runtime.hub.target_size(), runtime.previous.size)
|
|
};
|
|
let mut layers = cosmic::iced::widget::Stack::new();
|
|
if sample.previous_opacity > 0. {
|
|
layers = layers.push(content_frame::view(
|
|
self.hub_content(
|
|
id,
|
|
sample.previous,
|
|
sample.previous_opacity,
|
|
previous_menu,
|
|
false,
|
|
false,
|
|
),
|
|
previous_size,
|
|
panel.edge,
|
|
));
|
|
}
|
|
if sample.opacity > 0. {
|
|
layers = layers.push(content_frame::view(
|
|
container(self.hub_content(id, sample.current, sample.opacity, menu, true, false))
|
|
.id("ferese-hub-content")
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into(),
|
|
target_size,
|
|
panel.edge,
|
|
));
|
|
}
|
|
let body = container(layers)
|
|
.id("ferese-panel-hub")
|
|
// Snap dimensions before centering. Snapping the two edges of an
|
|
// unsnapped hub can alternate its width by a pixel on odd surfaces.
|
|
.width(grid.snap(bounds.width) as f32)
|
|
.height(grid.snap(bounds.height) as f32)
|
|
.clip(true);
|
|
let root = container(body)
|
|
.center_x(Length::Fill)
|
|
.height(Length::Fill)
|
|
.align_y(if panel.edge == Edge::Bottom {
|
|
cosmic::iced::alignment::Vertical::Bottom
|
|
} else {
|
|
cosmic::iced::alignment::Vertical::Top
|
|
});
|
|
let palette = self.config.theme.for_bar();
|
|
let mut background = palette.surface_raised.map(|value| f32::from(value) / 255.);
|
|
background[3] = palette.panel_opacity(panel.background_opacity);
|
|
let background = cosmic::iced::Color::from_rgba(
|
|
background[0],
|
|
background[1],
|
|
background[2],
|
|
background[3],
|
|
);
|
|
let mut border = crate::color(palette.border);
|
|
if let Some((tint, alpha)) = self.activity_result_tint(id) {
|
|
border.r += (tint.r - border.r) * alpha;
|
|
border.g += (tint.g - border.g) * alpha;
|
|
border.b += (tint.b - border.b) * alpha;
|
|
}
|
|
ferese_theme::panel_surface::frame(
|
|
self.with_workspace_hover(id, root.into()),
|
|
ferese_theme::panel_surface::Options {
|
|
parameters: ferese_panel::scene::Parameters {
|
|
radius: Some(ferese_panel::shape::AnchoredRadii(
|
|
sample.geometry.radii.map(|r| r as f32),
|
|
)),
|
|
..panel.shape_parameters()
|
|
},
|
|
bounds: Some(cosmic::iced::advanced::widget::Id::new("ferese-panel-hub")),
|
|
groups: Vec::new(),
|
|
scale: entry.scale,
|
|
high_contrast: palette.high_contrast,
|
|
background,
|
|
border: if panel.border {
|
|
border
|
|
} else {
|
|
cosmic::iced::Color::TRANSPARENT
|
|
},
|
|
divider: cosmic::iced::Color::TRANSPARENT,
|
|
native: effects.is_some(),
|
|
},
|
|
move |scene| {
|
|
if let Some(effects) = effects
|
|
&& let Err(error) = effects.set_panel_presentation(scene)
|
|
{
|
|
eprintln!("ferese-shell: could not update hub material: {error}");
|
|
}
|
|
},
|
|
Some(Box::new(move |scene| {
|
|
cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone())))
|
|
})),
|
|
)
|
|
}
|
|
|
|
fn hub_content(
|
|
&self,
|
|
id: window::Id,
|
|
mode: Mode,
|
|
opacity: f32,
|
|
menu: Option<status_ui::Menu>,
|
|
interactive: bool,
|
|
intrinsic: bool,
|
|
) -> Element<'_, cosmic::Action<Message>> {
|
|
let palette = self.config.theme;
|
|
let primary = color_with_opacity(palette.text_primary, opacity);
|
|
let icon_primary = crate::color(palette.text_primary);
|
|
let icon_muted = crate::color(palette.text_muted);
|
|
let peek = self
|
|
.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.runtime.hub.as_ref())
|
|
.map_or(Peek::Date, |runtime| {
|
|
let runtime = runtime.borrow();
|
|
if !interactive && !intrinsic {
|
|
runtime.previous.peek
|
|
} else {
|
|
runtime.peek
|
|
}
|
|
});
|
|
let content: Element<'_, cosmic::Action<Message>> = match mode {
|
|
Mode::Idle | Mode::Peek
|
|
if self.has_compact_activities(id)
|
|
&& self.hub_priority_for(id).is_none()
|
|
&& !(mode == Mode::Peek && peek == Peek::Workspaces) =>
|
|
{
|
|
self.activity_compact(id, opacity, intrinsic)
|
|
}
|
|
Mode::Idle | Mode::Peek | Mode::Toast => {
|
|
let playing = self
|
|
.media
|
|
.snapshot
|
|
.selected
|
|
.as_ref()
|
|
.filter(|player| player.status == ferese_ipc::media::Playback::Playing);
|
|
let time = self
|
|
.clock
|
|
.split_once(", ")
|
|
.map_or(self.clock.as_str(), |(_, time)| time);
|
|
let priority = self.hub_priority_for(id);
|
|
let activity = if mode == Mode::Idle {
|
|
priority
|
|
} else if mode == Mode::Peek
|
|
&& matches!(peek, Peek::Recording | Peek::Capture | Peek::Battery)
|
|
{
|
|
Some(peek)
|
|
} else {
|
|
None
|
|
};
|
|
let label = if mode == Mode::Toast {
|
|
self.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.runtime.hub.as_ref())
|
|
.and_then(|hub| {
|
|
let hub = hub.borrow();
|
|
if !interactive && !intrinsic {
|
|
hub.previous.toast
|
|
} else {
|
|
hub.transient.toast()
|
|
}
|
|
})
|
|
.and_then(|id| {
|
|
self.notifications
|
|
.entries
|
|
.iter()
|
|
.find(|notice| notice.id == id)
|
|
})
|
|
.map_or_else(|| time.to_owned(), |notice| notice.title.clone())
|
|
} else if mode == Mode::Peek {
|
|
match peek {
|
|
Peek::Recording | Peek::Capture | Peek::Battery => String::new(),
|
|
Peek::Media => self.media.snapshot.selected.as_ref().map_or_else(
|
|
|| "Playback ended".to_owned(),
|
|
|player| player.label().to_owned(),
|
|
),
|
|
Peek::Date => self.clock.clone(),
|
|
Peek::Workspaces => self
|
|
.output_for_bar(id)
|
|
.and_then(|output| {
|
|
self.snapshot
|
|
.workspaces
|
|
.iter()
|
|
.find(|w| w.id == output.active_workspace)
|
|
})
|
|
.map_or_else(
|
|
|| "Workspaces".to_owned(),
|
|
|workspace| {
|
|
if workspace.name == workspace.index.to_string() {
|
|
format!("Workspace {}", workspace.index)
|
|
} else {
|
|
format!("{} · {}", workspace.index, workspace.name)
|
|
}
|
|
},
|
|
),
|
|
}
|
|
} else if let Some(player) = playing {
|
|
player.label().to_owned()
|
|
} else {
|
|
let title =
|
|
crate::bar::focused_bar_title(&self.snapshot, self.output_for_bar(id));
|
|
if title.trim().is_empty() {
|
|
self.clock
|
|
.split_once(", ")
|
|
.map_or(self.clock.as_str(), |(date, _)| date)
|
|
} else {
|
|
title
|
|
}
|
|
.to_owned()
|
|
};
|
|
let kind = if let Some(activity) = activity {
|
|
match activity {
|
|
Peek::Capture => status_ui::Menu::CaptureStatus,
|
|
Peek::Recording => status_ui::Menu::Overflow,
|
|
_ => status_ui::Menu::Battery,
|
|
}
|
|
} else if mode == Mode::Toast {
|
|
status_ui::Menu::Notifications
|
|
} else if mode == Mode::Peek {
|
|
match peek {
|
|
Peek::Recording => status_ui::Menu::Overflow,
|
|
Peek::Capture => status_ui::Menu::CaptureStatus,
|
|
Peek::Battery => status_ui::Menu::Battery,
|
|
Peek::Media => status_ui::Menu::Media,
|
|
Peek::Date => status_ui::Menu::Workspaces,
|
|
Peek::Workspaces => status_ui::Menu::Workspaces,
|
|
}
|
|
} else if playing.is_some() {
|
|
status_ui::Menu::Media
|
|
} else if crate::bar::focused_bar_title(&self.snapshot, self.output_for_bar(id))
|
|
.trim()
|
|
.is_empty()
|
|
{
|
|
status_ui::Menu::Workspaces
|
|
} else {
|
|
self.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.runtime.hub.as_ref())
|
|
.map_or(status_ui::Menu::Workspaces, |runtime| {
|
|
runtime.borrow().last_menu
|
|
})
|
|
};
|
|
let label = self
|
|
.render
|
|
.text(label)
|
|
.size(13)
|
|
.class(theme::Text::Color(primary))
|
|
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
|
|
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
|
|
));
|
|
let body: Element<'_, cosmic::Action<Message>> = match mode {
|
|
_ if activity.is_some() => self.hub_activity_strip(
|
|
id,
|
|
activity.expect(
|
|
"the activity-specific hub view requires its selected activity",
|
|
),
|
|
mode == Mode::Peek,
|
|
intrinsic,
|
|
opacity,
|
|
),
|
|
Mode::Peek => {
|
|
let leading = match peek {
|
|
Peek::Recording | Peek::Capture | Peek::Battery => {
|
|
unreachable!("activity strip handled above")
|
|
}
|
|
Peek::Media => self.hub_music_art(opacity),
|
|
Peek::Workspaces => self.hub_workspaces(id, opacity),
|
|
Peek::Date => icons::tinted(icons::morph::CALENDAR, 18, icon_primary)
|
|
.opacity(opacity)
|
|
.into(),
|
|
};
|
|
let width = if intrinsic {
|
|
Length::Shrink
|
|
} else {
|
|
Length::Fill
|
|
};
|
|
let label = label.width(width).align_x(if peek == Peek::Date {
|
|
Alignment::End
|
|
} else {
|
|
Alignment::Start
|
|
});
|
|
let mut strip = row![leading, label]
|
|
.spacing(10)
|
|
.width(width)
|
|
.align_y(Alignment::Center);
|
|
if peek == Peek::Media {
|
|
strip = strip.push(self.hub_equalizer(id, opacity));
|
|
}
|
|
strip.into()
|
|
}
|
|
Mode::Toast => row![
|
|
icons::tinted(icons::morph::NOTIFICATIONS, 16, icon_muted).opacity(opacity),
|
|
label.width(Length::Fill)
|
|
]
|
|
.spacing(10)
|
|
.align_y(Alignment::Center)
|
|
.into(),
|
|
_ if playing.is_some() => {
|
|
// Measure the title only after reserving both side widgets,
|
|
// outer padding, and gaps. During rendering, flex gives it
|
|
// whatever remains as the shell animates.
|
|
let workspaces = self.hub_workspace_width(id) as f32;
|
|
let title_limit = (self.hub_idle_width_limit(id)
|
|
- if workspaces > 0. {
|
|
workspaces + 10.
|
|
} else {
|
|
0.
|
|
}
|
|
- 28.
|
|
- 24.
|
|
- 13.
|
|
- 16.)
|
|
.max(0.);
|
|
let title = if intrinsic {
|
|
container(label).max_width(title_limit)
|
|
} else {
|
|
container(label.width(Length::Fill)).width(Length::Fill)
|
|
};
|
|
row![
|
|
self.hub_music_art(opacity),
|
|
title,
|
|
self.hub_equalizer(id, opacity)
|
|
]
|
|
.spacing(8)
|
|
.width(if intrinsic {
|
|
Length::Shrink
|
|
} else {
|
|
Length::Fill
|
|
})
|
|
.align_y(Alignment::Center)
|
|
.into()
|
|
}
|
|
_ => label.into(),
|
|
};
|
|
let appearance = button::Style {
|
|
text_color: Some(primary),
|
|
icon_color: Some(primary),
|
|
..Default::default()
|
|
};
|
|
let content_width = if intrinsic
|
|
|| (mode == Mode::Idle && (playing.is_none() || activity.is_some()))
|
|
{
|
|
Length::Shrink
|
|
} else {
|
|
Length::Fill
|
|
};
|
|
let content_height = if intrinsic {
|
|
Length::Fixed(28.)
|
|
} else {
|
|
Length::Fill
|
|
};
|
|
let context = button::custom(
|
|
container(body)
|
|
.width(content_width)
|
|
.center_y(content_height),
|
|
)
|
|
.name(match activity {
|
|
Some(Peek::Capture) => format!(
|
|
"{}. Open capture status",
|
|
self.capture_status.label().unwrap_or_default()
|
|
),
|
|
Some(Peek::Recording) => format!(
|
|
"{}. Open recording controls",
|
|
self.recorder.elapsed().map_or_else(
|
|
|| "Saving recording".into(),
|
|
|elapsed| format!("Recording, {elapsed}")
|
|
)
|
|
),
|
|
Some(Peek::Battery) => "Low battery. Open battery controls".into(),
|
|
_ => "Expand panel hub".into(),
|
|
})
|
|
.padding(0)
|
|
.width(content_width)
|
|
.height(content_height)
|
|
.class(theme::Button::Custom {
|
|
active: Box::new(move |_, _| appearance),
|
|
hovered: Box::new(move |_, _| appearance),
|
|
pressed: Box::new(move |_, _| appearance),
|
|
disabled: Box::new(move |_| appearance),
|
|
})
|
|
.on_press(cosmic::Action::App(Message::HubOpen(id, kind)));
|
|
let mut strip = row::with_capacity(2).spacing(10).align_y(Alignment::Center);
|
|
if mode == Mode::Idle && self.hub_workspace_width(id) > 0. {
|
|
strip = strip.push(self.hub_workspaces(id, opacity));
|
|
}
|
|
let group = container(
|
|
strip
|
|
.push(context)
|
|
.width(content_width)
|
|
.height(content_height),
|
|
)
|
|
.padding([0, 14])
|
|
.height(content_height);
|
|
if intrinsic {
|
|
group.into()
|
|
} else {
|
|
group.center_x(Length::Fill).into()
|
|
}
|
|
}
|
|
Mode::Expanded(selected) => {
|
|
let mut tabs = row::with_capacity(6)
|
|
.width(Length::Fill)
|
|
.spacing(2)
|
|
.align_y(Alignment::Center);
|
|
let shortcuts = self.hub_shortcuts(id);
|
|
for (kind, label, icon) in [
|
|
(
|
|
status_ui::Menu::Workspaces,
|
|
"Workspaces",
|
|
icons::morph::WORKSPACES,
|
|
),
|
|
(status_ui::Menu::Media, "Music", icons::morph::MUSIC),
|
|
(
|
|
status_ui::Menu::Calendar,
|
|
"Calendar",
|
|
icons::morph::CALENDAR,
|
|
),
|
|
(
|
|
status_ui::Menu::Notifications,
|
|
"Notifications",
|
|
icons::morph::NOTIFICATIONS,
|
|
),
|
|
(
|
|
status_ui::Menu::Overflow,
|
|
"Controls",
|
|
icons::morph::CONTROLS,
|
|
),
|
|
(
|
|
status_ui::Menu::Activities,
|
|
"Activities",
|
|
icons::morph::ACTIVITIES,
|
|
),
|
|
] {
|
|
if kind == status_ui::Menu::Media && self.media.snapshot.selected.is_none() {
|
|
continue;
|
|
}
|
|
if kind == status_ui::Menu::Activities {
|
|
// Activities follows both the main tabs and optional shortcuts.
|
|
for shortcut in shortcuts
|
|
.iter()
|
|
.copied()
|
|
.filter(|kind| *kind != ItemKind::Overview)
|
|
{
|
|
tabs = tabs.push(self.hub_shortcut_button(id, shortcut, menu, opacity));
|
|
}
|
|
if !self.activities_configured(id) {
|
|
continue;
|
|
}
|
|
}
|
|
let active = if selected == Content::Controls {
|
|
menu.unwrap_or(status_ui::Menu::Overflow) == kind
|
|
} else {
|
|
selected == content(kind)
|
|
};
|
|
let unread = if kind == status_ui::Menu::Notifications {
|
|
self.notifications.unread()
|
|
} else {
|
|
0
|
|
};
|
|
let foreground = if unread > 0 {
|
|
crate::color(palette.accent)
|
|
} else {
|
|
icon_primary
|
|
};
|
|
let accessible_name = if unread > 0 {
|
|
format!("{label}, {unread} unread")
|
|
} else {
|
|
label.to_owned()
|
|
};
|
|
tabs = tabs.push(motion::button(
|
|
self.render,
|
|
button::custom(icons::tinted(icon, 18, foreground).opacity(opacity))
|
|
.name(accessible_name)
|
|
.padding([5, 3])
|
|
.width(30)
|
|
.height(28)
|
|
.on_press(cosmic::Action::App(Message::HubOpen(id, kind))),
|
|
primary,
|
|
active,
|
|
opacity,
|
|
));
|
|
}
|
|
let navigation = scrollable(tabs.width(Length::Shrink))
|
|
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
|
cosmic::iced::widget::scrollable::Scrollbar::default()
|
|
.width(0)
|
|
.scroller_width(0),
|
|
))
|
|
.width(Length::Fill);
|
|
let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center);
|
|
let mut tabs = tabs.push(navigation);
|
|
tabs = tabs.push(self.hub_shortcut_button(id, ItemKind::NightLight, menu, opacity));
|
|
if let Some(battery) =
|
|
self.hub_battery(id, menu == Some(status_ui::Menu::Battery), opacity)
|
|
{
|
|
tabs = tabs.push(battery);
|
|
}
|
|
tabs = tabs.push(motion::button(
|
|
self.render,
|
|
button::custom(
|
|
icons::tinted(icons::morph::CLOSE, 18, icon_primary).opacity(opacity),
|
|
)
|
|
.name("Collapse panel hub")
|
|
.padding([5, 3])
|
|
.width(30)
|
|
.height(28)
|
|
.on_press(cosmic::Action::App(Message::HubClose(id))),
|
|
primary,
|
|
false,
|
|
opacity,
|
|
));
|
|
let kind = match selected {
|
|
Content::Activities => status_ui::Menu::Activities,
|
|
Content::Workspaces => status_ui::Menu::Workspaces,
|
|
Content::Media => status_ui::Menu::Media,
|
|
Content::Calendar => status_ui::Menu::Calendar,
|
|
Content::Notifications => status_ui::Menu::Notifications,
|
|
Content::Controls => menu.unwrap_or(status_ui::Menu::Overflow),
|
|
};
|
|
let body = if kind == status_ui::Menu::Activities {
|
|
self.activity_list(id, opacity)
|
|
} else if kind == status_ui::Menu::Workspaces {
|
|
self.hub_workspace_grid(id, opacity)
|
|
} else if kind == status_ui::Menu::Overflow {
|
|
self.hub_controls(id, opacity)
|
|
} else {
|
|
self.view_hub_menu(kind, opacity)
|
|
};
|
|
column![
|
|
container(tabs).height(32),
|
|
scrollable(body)
|
|
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
|
|
cosmic::iced::widget::scrollable::Scrollbar::default()
|
|
.width(0)
|
|
.scroller_width(0)
|
|
))
|
|
.height(Length::Fill)
|
|
]
|
|
.spacing(8)
|
|
.padding([8, 14])
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
};
|
|
let content = if matches!(mode, Mode::Idle | Mode::Peek) {
|
|
scroll::area(content, id)
|
|
} else {
|
|
content
|
|
};
|
|
if interactive && opacity >= 0.5 {
|
|
content
|
|
} else {
|
|
content.map(|_| cosmic::Action::App(Message::HubIgnore))
|
|
}
|
|
}
|
|
|
|
fn hub_music_art(&self, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
|
match self
|
|
.media
|
|
.artwork
|
|
.as_ref()
|
|
.and_then(|art| art.handle.as_ref())
|
|
{
|
|
Some(handle) => cosmic::widget::image(handle.clone())
|
|
.width(24)
|
|
.height(24)
|
|
.opacity(opacity)
|
|
.content_fit(cosmic::iced::ContentFit::Cover)
|
|
.border_radius(5.)
|
|
.into(),
|
|
None => container(
|
|
icons::tinted(
|
|
icons::morph::MUSIC,
|
|
18,
|
|
crate::color(self.config.theme.text_primary),
|
|
)
|
|
.opacity(opacity),
|
|
)
|
|
.center_x(24)
|
|
.center_y(24)
|
|
.into(),
|
|
}
|
|
}
|
|
|
|
fn hub_equalizer(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
|
let playing = self
|
|
.media
|
|
.snapshot
|
|
.selected
|
|
.as_ref()
|
|
.is_some_and(|player| player.status == ferese_ipc::media::Playback::Playing);
|
|
let animate = playing && self.render.motion.motion_enabled();
|
|
let started = self
|
|
.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.runtime.hub.as_ref())
|
|
.map_or_else(Instant::now, |runtime| runtime.borrow().revision);
|
|
equalizer::view(
|
|
started,
|
|
animate,
|
|
color_with_opacity(self.config.theme.accent, opacity),
|
|
)
|
|
}
|
|
|
|
fn hub_shortcut_button(
|
|
&self,
|
|
id: window::Id,
|
|
kind: ItemKind,
|
|
menu: Option<status_ui::Menu>,
|
|
opacity: f32,
|
|
) -> Element<'_, cosmic::Action<Message>> {
|
|
let action = self.hub_control_action(id, kind).expect("shortcut action");
|
|
let night_light_on = kind == ItemKind::NightLight
|
|
&& self
|
|
.status
|
|
.night_light
|
|
.as_ref()
|
|
.is_some_and(|status| status.enabled);
|
|
let palette = self.config.theme.for_bar();
|
|
let primary = crate::color(if night_light_on {
|
|
palette.accent
|
|
} else {
|
|
palette.text_primary
|
|
});
|
|
motion::button(
|
|
self.render,
|
|
button::custom(icons::tinted(action.icon, 18, primary).opacity(opacity))
|
|
.name(if kind == ItemKind::NightLight {
|
|
if night_light_on {
|
|
"Night light: on"
|
|
} else {
|
|
"Night light: off"
|
|
}
|
|
} else {
|
|
action.label
|
|
})
|
|
.padding([5, 3])
|
|
.width(30)
|
|
.height(28)
|
|
.on_press_maybe(action.message.map(cosmic::Action::App)),
|
|
primary.scale_alpha(opacity),
|
|
action.menu.is_some() && action.menu == menu,
|
|
opacity,
|
|
)
|
|
}
|
|
|
|
fn hub_control_action(&self, id: window::Id, kind: ItemKind) -> Option<ControlAction> {
|
|
let (icon, menu) = match kind {
|
|
ItemKind::Overview => (icons::morph::WORKSPACES, None),
|
|
ItemKind::Activities => (icons::morph::ACTIVITIES, Some(status_ui::Menu::Activities)),
|
|
ItemKind::QuickSettings => (icons::morph::SETTINGS, Some(status_ui::Menu::System)),
|
|
ItemKind::NightLight => (ferese_theme::icons::MOON, Some(status_ui::Menu::NightLight)),
|
|
ItemKind::Network => (icons::WIFI, Some(status_ui::Menu::Network)),
|
|
ItemKind::CaptureStatus if self.capture_status.active() => (
|
|
self.capture_status.icon(),
|
|
Some(status_ui::Menu::CaptureStatus),
|
|
),
|
|
ItemKind::Recording => (
|
|
match self.recorder.state {
|
|
crate::recording::State::Selecting => icons::RECORD_CANCEL,
|
|
crate::recording::State::Recording(_) => icons::RECORD_STOP,
|
|
crate::recording::State::Saving => icons::RECORD_SAVING,
|
|
_ => icons::morph::RECORD,
|
|
},
|
|
None,
|
|
),
|
|
ItemKind::Audio => (icons::VOLUME, Some(status_ui::Menu::Audio)),
|
|
ItemKind::Battery { .. } => (icons::BATTERY, Some(status_ui::Menu::Battery)),
|
|
ItemKind::DisplayMode if self.display_mode.external_connected() => {
|
|
(icons::morph::DISPLAY, None)
|
|
}
|
|
_ => return None,
|
|
};
|
|
let message = match kind {
|
|
ItemKind::Overview => Some(Message::ToggleOverview),
|
|
ItemKind::Recording => match self.recorder.state {
|
|
crate::recording::State::Selecting | crate::recording::State::Recording(_) => {
|
|
Some(Message::StopRecording)
|
|
}
|
|
crate::recording::State::Saving => None,
|
|
_ => Some(Message::StartRecording),
|
|
},
|
|
ItemKind::DisplayMode => Some(Message::OpenDisplays(
|
|
self.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.name.clone()),
|
|
)),
|
|
_ => menu.map(|menu| Message::HubOpen(id, menu)),
|
|
};
|
|
Some(ControlAction {
|
|
icon,
|
|
menu,
|
|
message,
|
|
label: if kind == ItemKind::Recording {
|
|
self.recorder.label()
|
|
} else {
|
|
kind.label()
|
|
},
|
|
})
|
|
}
|
|
|
|
fn hub_shortcuts(&self, id: window::Id) -> Vec<ItemKind> {
|
|
let panel = self.panel_for_bar(id);
|
|
let available = content_width(
|
|
panel,
|
|
self.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)
|
|
.and_then(|entry| entry.size),
|
|
);
|
|
let width = panel.morph.expanded_width.min(available);
|
|
// Built-in tabs, battery and Close keep their space through the morph.
|
|
let tabs = 6.
|
|
+ f64::from(self.media.snapshot.selected.is_some())
|
|
+ f64::from(self.activities_configured(id));
|
|
let battery = if self.status.battery.is_some() {
|
|
battery::WIDTH + 2.
|
|
} else {
|
|
0.
|
|
};
|
|
let capacity = ((width - 28. - tabs * 32. - battery - 8.).max(0.) / 32.).floor() as usize;
|
|
let mut shortcuts = Vec::new();
|
|
for item in [&panel.start, &panel.center, &panel.end]
|
|
.into_iter()
|
|
.flat_map(|zone| &zone.groups)
|
|
.flat_map(|group| &group.items)
|
|
.filter(|item| item.visible && item.overflow != ferese_panel::OverflowPolicy::Always)
|
|
{
|
|
if shortcuts.len() == capacity {
|
|
break;
|
|
}
|
|
if matches!(
|
|
item.kind,
|
|
ItemKind::Overview | ItemKind::Activities | ItemKind::NightLight
|
|
) {
|
|
continue;
|
|
}
|
|
if matches!(
|
|
item.kind,
|
|
ItemKind::Network | ItemKind::Audio | ItemKind::Battery { .. }
|
|
) {
|
|
continue;
|
|
}
|
|
if self.hub_control_action(id, item.kind).is_some() && !shortcuts.contains(&item.kind) {
|
|
shortcuts.push(item.kind);
|
|
}
|
|
}
|
|
shortcuts
|
|
}
|
|
|
|
fn hub_controls(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
|
let primary = color_with_opacity(self.config.theme.text_primary, opacity);
|
|
let mut rows = column::with_capacity(8).spacing(2).width(Length::Fill);
|
|
let mut tiles = Vec::new();
|
|
let panel = self.panel_for_bar(id);
|
|
let shortcuts = self.hub_shortcuts(id);
|
|
for item in [&panel.start, &panel.center, &panel.end]
|
|
.into_iter()
|
|
.flat_map(|zone| &zone.groups)
|
|
.flat_map(|group| &group.items)
|
|
.filter(|item| item.visible)
|
|
{
|
|
if shortcuts.contains(&item.kind) || matches!(item.kind, ItemKind::Battery { .. }) {
|
|
continue;
|
|
}
|
|
if matches!(item.kind, ItemKind::Overview | ItemKind::Activities) {
|
|
continue;
|
|
}
|
|
let Some(action) = self.hub_control_action(id, item.kind) else {
|
|
continue;
|
|
};
|
|
let ControlAction {
|
|
icon,
|
|
label,
|
|
message,
|
|
..
|
|
} = action;
|
|
tiles.push(motion::button(
|
|
self.render,
|
|
button::custom(
|
|
row![
|
|
container(
|
|
icons::tinted(icon, 22, crate::color(self.config.theme.text_primary))
|
|
.opacity(opacity)
|
|
)
|
|
.center_x(30)
|
|
.center_y(28),
|
|
self.render
|
|
.text(label)
|
|
.size(12)
|
|
.class(theme::Text::Color(primary))
|
|
.height(28)
|
|
.width(Length::Fill)
|
|
.align_y(cosmic::iced::alignment::Vertical::Center)
|
|
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
|
|
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
|
|
))
|
|
]
|
|
.spacing(8)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.name(label)
|
|
.padding([4, 8])
|
|
.height(36)
|
|
.width(Length::Fill)
|
|
.on_press_maybe(message.map(cosmic::Action::App)),
|
|
primary,
|
|
false,
|
|
opacity,
|
|
));
|
|
}
|
|
let mut tiles = tiles.into_iter();
|
|
while let Some(left) = tiles.next() {
|
|
let right = tiles
|
|
.next()
|
|
.unwrap_or_else(|| cosmic::widget::Space::new().width(Length::Fill).into());
|
|
rows = rows.push(row![left, right].spacing(8));
|
|
}
|
|
rows.into()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use cosmic::Application;
|
|
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, widget};
|
|
use cosmic::iced::{Event, Rectangle, Size};
|
|
use std::time::Duration;
|
|
|
|
fn shell() -> FereseShell {
|
|
let mut shell = crate::tests::shell_with_measured_panel();
|
|
shell.config.panels[0].style = PanelStyle::Morph;
|
|
shell.config.panels[0].border = true;
|
|
shell.outputs[0]
|
|
.runtime
|
|
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
|
|
shell.clock = "11 oct, 10:09 am".into();
|
|
shell
|
|
}
|
|
|
|
#[test]
|
|
fn queued_hub_frame_after_output_removal_is_empty_instead_of_panicking() {
|
|
let mut app = shell();
|
|
let id = app.outputs[0].bar;
|
|
let sample = app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.sample();
|
|
app.outputs.clear();
|
|
drop(app.view_hub(id));
|
|
drop(app.view_hub_at(id, sample, None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn saved_regular_margins_do_not_constrain_morph() {
|
|
let mut app = shell();
|
|
let bar = app.outputs[0].bar;
|
|
app.outputs[0].size = Some((1280, 720));
|
|
let baseline = content_width(&app.config.panels[0], app.outputs[0].size);
|
|
let mut config = app.config.clone();
|
|
config.panels[0].style = PanelStyle::Flat;
|
|
config.panels[0].geometry.side_margins = Some(640);
|
|
drop(app.apply_config(config.clone()));
|
|
config.panels[0].style = PanelStyle::Morph;
|
|
drop(app.apply_config(config.clone()));
|
|
assert_eq!(
|
|
content_width(&app.config.panels[0], app.outputs[0].size),
|
|
baseline
|
|
);
|
|
drop(app.open_hub(bar, status_ui::Menu::Calendar));
|
|
assert!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size()[0]
|
|
> 300.
|
|
);
|
|
config.panels[0].style = PanelStyle::Flat;
|
|
drop(app.apply_config(config));
|
|
assert_eq!(app.config.panels[0].geometry.side_margins, Some(640));
|
|
}
|
|
|
|
#[test]
|
|
fn large_radius_pins_survive_startup_reload_and_style_switches() {
|
|
for radius in [129., 4096., f32::MAX] {
|
|
let mut app = shell();
|
|
let mut config = app.config.clone();
|
|
config.panels[0].corner_radius = Some(ferese_panel::CornerRadii::uniform(radius));
|
|
ferese_panel::validate(&config.panels).unwrap();
|
|
let mut startup = Runtime::new(&config.panels[0]);
|
|
for _ in 0..60 {
|
|
startup.hub.advance(Duration::from_millis(8), false);
|
|
assert!(
|
|
startup
|
|
.hub
|
|
.sample()
|
|
.geometry
|
|
.radii
|
|
.iter()
|
|
.all(|r| (*r as f32).is_finite())
|
|
);
|
|
}
|
|
drop(app.apply_config(config.clone()));
|
|
config.panels[0].style = PanelStyle::Flat;
|
|
drop(app.apply_config(config.clone()));
|
|
config.panels[0].style = PanelStyle::Morph;
|
|
drop(app.apply_config(config));
|
|
assert!(app.outputs[0].runtime.hub.is_some());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn activity_destination_remains_available_for_every_overflow_policy() {
|
|
let mut app = shell();
|
|
seed_activities(&mut app, 1);
|
|
let bar = app.outputs[0].bar;
|
|
for policy in [
|
|
ferese_panel::OverflowPolicy::Auto,
|
|
ferese_panel::OverflowPolicy::Always,
|
|
ferese_panel::OverflowPolicy::Never,
|
|
ferese_panel::OverflowPolicy::Always,
|
|
] {
|
|
let mut config = app.config.clone();
|
|
let panel = &mut config.panels[0];
|
|
for item in [&mut panel.start, &mut panel.center, &mut panel.end]
|
|
.into_iter()
|
|
.flat_map(|zone| &mut zone.groups)
|
|
.flat_map(|group| &mut group.items)
|
|
{
|
|
if item.kind == ItemKind::Activities {
|
|
item.overflow = policy;
|
|
}
|
|
}
|
|
drop(app.apply_config(config));
|
|
assert!(app.activities_configured(bar));
|
|
assert_eq!(
|
|
app.compact_activities_configured(bar),
|
|
policy != ferese_panel::OverflowPolicy::Always
|
|
);
|
|
drop(app.open_hub(bar, status_ui::Menu::Activities));
|
|
assert_eq!(app.hub_mode(bar), Some(Mode::Expanded(Content::Activities)));
|
|
if policy == ferese_panel::OverflowPolicy::Always {
|
|
assert!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.activities
|
|
.selection
|
|
.visible
|
|
.is_empty()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn output_resize_refits_every_mode_and_caps_the_retained_surface() {
|
|
for edge in [Edge::Top, Edge::Bottom] {
|
|
for attached in [false, true] {
|
|
for mode in [Mode::Idle, Mode::Peek, Mode::Expanded(Content::Calendar)] {
|
|
for settled in [false, true] {
|
|
let mut app = shell();
|
|
let panel = &mut app.config.panels[0];
|
|
panel.edge = edge;
|
|
panel.morph.attached = attached;
|
|
app.outputs[0]
|
|
.runtime
|
|
.reset(panel, &app.snapshot, Some("test"));
|
|
let bar = app.outputs[0].bar;
|
|
drop(app.transition_hub(
|
|
bar,
|
|
transition::Event::Present(
|
|
mode,
|
|
mode.expanded().then_some(status_ui::Menu::Calendar),
|
|
),
|
|
));
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow_mut()
|
|
.hub
|
|
.advance(Duration::from_millis(16), settled);
|
|
let mut info = cosmic::cctk::sctk::output::OutputData::new(1)
|
|
.with_output_info(Clone::clone);
|
|
info.logical_size = Some((220, 170));
|
|
let output = app.outputs[0].output.clone();
|
|
drop(app.output_event(
|
|
cosmic::iced::event::wayland::OutputEvent::InfoUpdate(info),
|
|
output,
|
|
));
|
|
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
|
let available = available_size(&app.config.panels[0], app.outputs[0].size);
|
|
assert_eq!(runtime.hub.mode(), mode);
|
|
for (i, limit) in available.into_iter().enumerate() {
|
|
assert!(f64::from(runtime.allocation[i]) <= limit.ceil());
|
|
assert!(runtime.hub.target_size()[i] <= limit);
|
|
}
|
|
assert!(
|
|
runtime.hub.target_size()[0]
|
|
<= content_width(&app.config.panels[0], app.outputs[0].size)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn seed_activities(shell: &mut FereseShell, count: usize) {
|
|
use ferese_activities::{Data, Owner, Source, Store, Template, Value};
|
|
let now = ferese_ipc::activities::now_ms();
|
|
if !shell.config.panels[0]
|
|
.end
|
|
.groups
|
|
.iter()
|
|
.flat_map(|group| &group.items)
|
|
.any(|item| item.kind == ItemKind::Activities)
|
|
{
|
|
shell.config.panels[0].end.groups[0]
|
|
.items
|
|
.push(ferese_panel::Item::new(
|
|
"test-activities",
|
|
ItemKind::Activities,
|
|
));
|
|
}
|
|
if shell.snapshot.outputs.is_empty() {
|
|
shell.snapshot.outputs.push(crate::control::OutputSnapshot {
|
|
id: 4,
|
|
name: "test".into(),
|
|
active_workspace: 1,
|
|
focused: true,
|
|
});
|
|
}
|
|
let mut store = Store::new(7);
|
|
for index in 0..count {
|
|
store
|
|
.start(
|
|
Owner { pid: 40, birth: 1 },
|
|
format!("task-{index}"),
|
|
Data {
|
|
source: Source::Publisher,
|
|
output: shell
|
|
.output_for_bar(shell.outputs[0].bar)
|
|
.map(|output| output.name.clone()),
|
|
priority: 100 + index as u8,
|
|
icon: if index == 0 { "hammer" } else { "download" }.into(),
|
|
title: if index == 2 {
|
|
"Installing update"
|
|
} else if index == 0 {
|
|
"Cargo test: a deliberately long title which must leave all neighboring controls accessible"
|
|
} else {
|
|
"Downloading update"
|
|
}
|
|
.into(),
|
|
value: if index == 0 {
|
|
Value::Indeterminate
|
|
} else {
|
|
Value::Progress(0.62)
|
|
},
|
|
template: Template::Progress,
|
|
},
|
|
now.saturating_sub(500),
|
|
0,
|
|
None,
|
|
true,
|
|
true,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
shell.activities.snapshot = std::sync::Arc::new(ferese_ipc::activities::Snapshot {
|
|
activities: store.entries().cloned().collect(),
|
|
now_ms: now,
|
|
revision: 1,
|
|
..Default::default()
|
|
});
|
|
}
|
|
#[test]
|
|
fn pomodoro_controls_fit_and_tab_enter_targets_the_current_session() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
use cosmic::iced::advanced::widget::operation;
|
|
use cosmic::iced::keyboard::{Event as KeyEvent, Key, Location, Modifiers, key};
|
|
use ferese_activities::pomodoro::{Session, Status};
|
|
use ferese_ipc::activities::PomodoroAction;
|
|
#[derive(Default)]
|
|
struct TextBounds(Vec<(String, Rectangle)>);
|
|
impl widget::Operation for TextBounds {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
|
visit(self);
|
|
}
|
|
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
|
self.0.push((text.into(), bounds));
|
|
}
|
|
}
|
|
let mut 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 = shell();
|
|
app.config.font_family = Some("JetBrains Mono".into());
|
|
app.render = crate::rendering::Rendering::new(&app.config);
|
|
let bar = app.outputs[0].bar;
|
|
seed_activities(&mut app, 1);
|
|
let id = app.activities.snapshot.activities[0].id.clone();
|
|
for status in [
|
|
None,
|
|
Some(Status::Running),
|
|
Some(Status::Paused),
|
|
Some(Status::Ready),
|
|
Some(Status::Finished),
|
|
] {
|
|
if let Some(status) = status {
|
|
let mut timer =
|
|
Session::start(Default::default(), ferese_ipc::activities::now_ms()).unwrap();
|
|
timer.status = status;
|
|
if status != Status::Running {
|
|
timer.deadline = None;
|
|
}
|
|
if status == Status::Finished {
|
|
timer.remaining_ms = 0;
|
|
timer.completed_work = timer.settings.intervals;
|
|
}
|
|
std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0]
|
|
.data
|
|
.title = "Pomodoro · Focus".into();
|
|
std::sync::Arc::make_mut(&mut app.activities.snapshot).pomodoro =
|
|
Some(ferese_ipc::activities::Pomodoro {
|
|
id: id.clone(),
|
|
timer,
|
|
});
|
|
}
|
|
for width in [240., 260., 312.] {
|
|
let mut view = app.activity_list(bar, 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(width, 600.)),
|
|
);
|
|
let mut labels = TextBounds::default();
|
|
view.as_widget_mut()
|
|
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
|
for (text, bounds) in &labels.0 {
|
|
assert!(
|
|
bounds.x >= 0. && bounds.x + bounds.width <= width + 0.5,
|
|
"{status:?} {width} {text}: {bounds:?}"
|
|
);
|
|
}
|
|
assert!(
|
|
!labels.0.iter().any(|(text, _)| text == "Pomodoro · Focus"),
|
|
"the timer must not appear twice"
|
|
);
|
|
if let Some(directory) = std::env::var_os("FERESE_POMODORO_CAPTURE_DIR") {
|
|
use cosmic::iced::advanced::renderer::Renderer as _;
|
|
let directory = std::path::PathBuf::from(directory);
|
|
std::fs::create_dir_all(&directory).unwrap();
|
|
let viewport = Rectangle::with_size(node.size());
|
|
renderer.reset(viewport);
|
|
view.as_widget().draw(
|
|
&tree,
|
|
&mut renderer,
|
|
&app.config.theme.palette().native_theme(),
|
|
&cosmic::iced::advanced::renderer::Style {
|
|
text_color: crate::color(app.config.theme.text_primary),
|
|
icon_color: crate::color(app.config.theme.text_primary),
|
|
scale_factor: 1.5,
|
|
},
|
|
Layout::new(&node),
|
|
mouse::Cursor::Unavailable,
|
|
&viewport,
|
|
);
|
|
let physical = Size::new(
|
|
(node.size().width * 1.5).ceil() as u32,
|
|
(node.size().height * 1.5).ceil() as u32,
|
|
);
|
|
let pixels = renderer.screenshot(
|
|
physical,
|
|
1.5,
|
|
crate::color(app.config.theme.surface_popover),
|
|
);
|
|
image_codec::save_buffer(
|
|
directory.join(format!("{status:?}-{width}.png")),
|
|
&pixels,
|
|
physical.width,
|
|
physical.height,
|
|
image_codec::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
}
|
|
let primary = match status {
|
|
Some(Status::Running) => PomodoroAction::Pause,
|
|
Some(Status::Paused | Status::Ready) => PomodoroAction::Resume,
|
|
_ => PomodoroAction::Start,
|
|
};
|
|
let mut expected = vec![primary];
|
|
if matches!(status, Some(Status::Running | Status::Paused)) {
|
|
expected.push(PomodoroAction::Next);
|
|
}
|
|
if status.is_some_and(|status| status != Status::Finished) {
|
|
expected.push(PomodoroAction::Reset);
|
|
}
|
|
if status.is_some() {
|
|
expected.push(PomodoroAction::Stop);
|
|
}
|
|
for expected in expected {
|
|
let mut tab: Box<dyn widget::Operation> =
|
|
Box::new(operation::focusable::focus_next());
|
|
loop {
|
|
view.as_widget_mut().operate(
|
|
&mut tree,
|
|
Layout::new(&node),
|
|
&renderer,
|
|
&mut tab,
|
|
);
|
|
match tab.finish() {
|
|
operation::Outcome::Chain(next) => tab = next,
|
|
_ => break,
|
|
}
|
|
}
|
|
let event = Event::Keyboard(KeyEvent::KeyPressed {
|
|
key: Key::Named(key::Named::Enter),
|
|
modified_key: Key::Named(key::Named::Enter),
|
|
physical_key: key::Physical::Code(key::Code::Enter),
|
|
location: Location::Standard,
|
|
modifiers: Modifiers::empty(),
|
|
text: None,
|
|
repeat: false,
|
|
});
|
|
let mut messages = Vec::new();
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&event,
|
|
Layout::new(&node),
|
|
mouse::Cursor::Unavailable,
|
|
&renderer,
|
|
&mut clipboard::Null,
|
|
&mut Shell::new(&mut messages),
|
|
&Rectangle::with_size(Size::new(width, 600.)),
|
|
);
|
|
assert!(messages.iter().any(|message|matches!(message,cosmic::Action::App(Message::Pomodoro(action,session)) if *action==expected && *session==status.map(|_|id.clone()))),"{status:?} {expected:?}: {messages:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn paused_pomodoro_never_requests_countdown_frames() {
|
|
let mut app = shell();
|
|
let bar = app.outputs[0].bar;
|
|
seed_activities(&mut app, 1);
|
|
let now = ferese_ipc::activities::now_ms();
|
|
let entry = &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0];
|
|
entry.data.template = ferese_activities::Template::Timer;
|
|
entry.data.value = ferese_activities::Value::Countdown {
|
|
remaining_ms: 50_000,
|
|
sampled_at: now,
|
|
};
|
|
drop(app.sync_hub_activity());
|
|
assert!(app.activity_tick_subscription().units() > 0);
|
|
std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0]
|
|
.data
|
|
.value = ferese_activities::Value::PausedCountdown {
|
|
remaining_ms: 50_000,
|
|
};
|
|
drop(app.sync_hub_activity());
|
|
assert_eq!(app.activity_tick_subscription().units(), 0);
|
|
assert!(app.activity_available(bar));
|
|
}
|
|
|
|
#[test]
|
|
fn activity_target_must_still_exist_and_activities_has_no_duplicate_shortcut() {
|
|
let mut app = shell();
|
|
let bar = app.outputs[0].bar;
|
|
seed_activities(&mut app, 1);
|
|
let activity = app.activities.snapshot.activities[0].id.clone();
|
|
assert_eq!(app.activity_window(&activity), None);
|
|
std::sync::Arc::make_mut(&mut app.activities.snapshot)
|
|
.windows
|
|
.push(ferese_ipc::activities::ActivityWindow {
|
|
activity: activity.clone(),
|
|
window: 42,
|
|
});
|
|
assert_eq!(app.activity_window(&activity), None);
|
|
app.snapshot.windows.push(crate::control::WindowSnapshot {
|
|
id: 42,
|
|
workspace: 1,
|
|
geometry: None,
|
|
app_id: "foot".into(),
|
|
title: "Terminal".into(),
|
|
focused: false,
|
|
urgent: false,
|
|
fullscreen: false,
|
|
maximized: false,
|
|
floating: false,
|
|
});
|
|
assert_eq!(app.activity_window(&activity), Some(42));
|
|
app.snapshot.windows.clear();
|
|
assert_eq!(app.activity_window(&activity), None);
|
|
app.config.panels[0].morph.expanded_width = 400.;
|
|
assert!(!app.hub_shortcuts(bar).contains(&ItemKind::Activities));
|
|
app.status.battery = None;
|
|
assert!(app.hub_battery(bar, false, 1.).is_none());
|
|
app.status.battery = Some(crate::status::Battery {
|
|
percent: 100,
|
|
status: "Full".into(),
|
|
});
|
|
assert!(app.hub_battery(bar, false, 1.).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn open_activity_list_survives_publisher_churn() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
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 = shell();
|
|
let id = app.outputs[0].bar;
|
|
seed_activities(&mut app, 3);
|
|
drop(app.sync_hub_activity());
|
|
drop(app.open_hub(id, status_ui::Menu::Activities));
|
|
let mut tree = widget::Tree::empty();
|
|
for count in [3, 2, 1, 3, 0, 2, 3] {
|
|
let opacity = app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.sample()
|
|
.opacity;
|
|
seed_activities(&mut app, count);
|
|
app.sync_activity_presentations();
|
|
assert_eq!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.sample()
|
|
.opacity,
|
|
opacity,
|
|
"publisher changes must not restart the open panel's crossfade"
|
|
);
|
|
for _ in 0..50 {
|
|
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap();
|
|
let (sample, previous_menu) = {
|
|
let mut runtime = runtime.borrow_mut();
|
|
runtime.hub.advance(Duration::from_millis(16), false);
|
|
(runtime.hub.sample(), runtime.previous.menu)
|
|
};
|
|
let mut view =
|
|
app.view_hub_at(id, sample, Some(status_ui::Menu::Activities), previous_menu);
|
|
widget::tree::NAMED.with(|named| *named.borrow_mut() = tree.take_all_named());
|
|
tree.diff(&mut view);
|
|
view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, Size::new(1200., 800.)),
|
|
);
|
|
widget::tree::NAMED.with(|named| named.borrow_mut().clear());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn activity_transitions_reconcile_the_existing_widget_tree() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
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 = shell();
|
|
let id = app.outputs[0].bar;
|
|
seed_activities(&mut app, 3);
|
|
drop(app.sync_hub_activity());
|
|
let mut tree = widget::Tree::empty();
|
|
for menu in [
|
|
None,
|
|
Some(status_ui::Menu::Activities),
|
|
Some(status_ui::Menu::Calendar),
|
|
Some(status_ui::Menu::Activities),
|
|
None,
|
|
] {
|
|
if let Some(menu) = menu {
|
|
drop(app.open_hub(id, menu));
|
|
} else {
|
|
drop(app.close_hub(id));
|
|
}
|
|
for _ in 0..50 {
|
|
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap();
|
|
let (sample, previous_menu) = {
|
|
let mut runtime = runtime.borrow_mut();
|
|
runtime.hub.advance(Duration::from_millis(16), false);
|
|
(runtime.hub.sample(), runtime.previous.menu)
|
|
};
|
|
let mut view = app.view_hub_at(id, sample, menu, previous_menu);
|
|
// Application rebuilds temporarily extract named widget state.
|
|
widget::tree::NAMED.with(|named| *named.borrow_mut() = tree.take_all_named());
|
|
tree.diff(&mut view);
|
|
view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, Size::new(1200., 800.)),
|
|
);
|
|
widget::tree::NAMED.with(|named| named.borrow_mut().clear());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn live_activity_keeps_workspace_wheel_handler() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, widget};
|
|
use cosmic::iced::{Event, Size, mouse};
|
|
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
|
cosmic::iced::Font::default(),
|
|
cosmic::iced::Pixels(14.),
|
|
Some("tiny-skia"),
|
|
))
|
|
.unwrap();
|
|
for with_activity in [false, true] {
|
|
let mut app = shell();
|
|
let id = app.outputs[0].bar;
|
|
if with_activity {
|
|
seed_activities(&mut app, 1);
|
|
}
|
|
drop(app.sync_hub_activity());
|
|
let mut view = app.hub_content(id, Mode::Peek, 1., None, true, false);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, Size::new(260., 44.)),
|
|
);
|
|
let viewport = cosmic::iced::Rectangle::with_size(Size::new(260., 44.));
|
|
let mut messages = Vec::new();
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&Event::Mouse(mouse::Event::WheelScrolled {
|
|
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
|
}),
|
|
Layout::new(&node),
|
|
mouse::Cursor::Available(cosmic::iced::Point::new(130., 20.)),
|
|
&renderer,
|
|
&mut clipboard::Null,
|
|
&mut Shell::new(&mut messages),
|
|
&viewport,
|
|
);
|
|
assert!(
|
|
messages.iter().any(|message| matches!(
|
|
message,
|
|
cosmic::Action::App(Message::HubWorkspaceScroll(_, _))
|
|
)),
|
|
"workspace scrolling stopped with_activity={with_activity}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_feedback_takes_precedence_over_compact_activities() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
#[derive(Debug, 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 = shell();
|
|
let bar = app.outputs[0].bar;
|
|
seed_activities(&mut app, 1);
|
|
app.snapshot.workspaces = vec![crate::control::WorkspaceSnapshot {
|
|
id: 1,
|
|
name: "Code".into(),
|
|
output: Some(4),
|
|
index: 1,
|
|
window_count: 1,
|
|
visible: true,
|
|
focused: true,
|
|
}];
|
|
drop(app.sync_hub_activity());
|
|
drop(app.transition_hub(bar, transition::Event::Workspaces(Instant::now())));
|
|
assert_eq!(app.hub_mode(bar), Some(Mode::Peek));
|
|
let mut view = app.hub_content(bar, Mode::Peek, 1., None, true, false);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, Size::new(260., 44.)),
|
|
);
|
|
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 == "1 · Code"), "{labels:?}");
|
|
assert!(!labels.0.iter().any(|text| text.contains("Cargo test")));
|
|
}
|
|
|
|
#[test]
|
|
fn dnd_list_keeps_result_rows_reachable_and_preserves_focus_on_update() {
|
|
use cosmic::iced::keyboard::key::Named;
|
|
let mut app = shell();
|
|
let bar = app.outputs[0].bar;
|
|
seed_activities(&mut app, 3);
|
|
let activity = &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0];
|
|
activity.state = ferese_activities::State::Done;
|
|
activity.result_deadline = Some(ferese_ipc::activities::now_ms() + 10_000);
|
|
let completed = activity.id.clone();
|
|
app.notifications.dnd = true;
|
|
drop(app.sync_hub_activity());
|
|
drop(app.open_hub(bar, status_ui::Menu::Activities));
|
|
let expected: Vec<_> = ferese_activities::ranked(
|
|
app.activities.snapshot.activities.iter(),
|
|
ferese_ipc::activities::now_ms(),
|
|
app.activities.snapshot.limits,
|
|
app.output_for_bar(bar).unwrap().name.as_str(),
|
|
false,
|
|
)
|
|
.iter()
|
|
.map(|entry| entry.id.clone())
|
|
.collect();
|
|
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap();
|
|
assert!(expected.contains(&completed));
|
|
assert!(
|
|
!runtime
|
|
.borrow()
|
|
.activities
|
|
.selection
|
|
.visible
|
|
.contains(&completed)
|
|
);
|
|
for (index, id) in expected.iter().enumerate() {
|
|
let key = if index == 0 {
|
|
Named::Home
|
|
} else {
|
|
Named::ArrowDown
|
|
};
|
|
drop(app.activity_navigation(bar, None, Some(key)));
|
|
assert_eq!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.activities
|
|
.focus
|
|
.as_ref(),
|
|
Some(id)
|
|
);
|
|
}
|
|
drop(app.activity_navigation(bar, Some(completed.clone()), None));
|
|
drop(app.sync_hub_activity());
|
|
assert_eq!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.activities
|
|
.focus,
|
|
Some(completed)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn activity_arrows_follow_tab_focus() {
|
|
use cosmic::iced::Size;
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
use cosmic::iced::advanced::widget::operation;
|
|
use cosmic::iced::advanced::{Layout, layout, widget};
|
|
use cosmic::iced::keyboard::key::Named;
|
|
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 = shell();
|
|
let bar = app.outputs[0].bar;
|
|
seed_activities(&mut app, 3);
|
|
drop(app.sync_hub_activity());
|
|
drop(app.open_hub(bar, status_ui::Menu::Activities));
|
|
let ranked = ferese_activities::ranked(
|
|
app.activities.snapshot.activities.iter(),
|
|
ferese_ipc::activities::now_ms(),
|
|
app.activities.snapshot.limits,
|
|
app.output_for_bar(bar).unwrap().name.as_str(),
|
|
false,
|
|
);
|
|
let ids: Vec<_> = ranked.iter().map(|entry| entry.id.clone()).collect();
|
|
let id = &ids[0];
|
|
let first = widget::Id::new(format!(
|
|
"activity-{bar:?}-{}-{}-{}-{}",
|
|
id.session, id.generation, id.owner.pid, id.owner.birth
|
|
));
|
|
let focused = {
|
|
let mut query = app.activity_focus_query(bar).unwrap();
|
|
let mut view = app.activity_list(bar, 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(312., 600.)),
|
|
);
|
|
view.as_widget_mut().operate(
|
|
&mut tree,
|
|
Layout::new(&node),
|
|
&renderer,
|
|
&mut operation::focusable::focus::<()>(first),
|
|
);
|
|
let mut tab: Box<dyn widget::Operation> = Box::new(operation::focusable::focus_next());
|
|
loop {
|
|
view.as_widget_mut()
|
|
.operate(&mut tree, Layout::new(&node), &renderer, &mut tab);
|
|
match tab.finish() {
|
|
operation::Outcome::Chain(next) => tab = next,
|
|
_ => break,
|
|
}
|
|
}
|
|
view.as_widget_mut().operate(
|
|
&mut tree,
|
|
Layout::new(&node),
|
|
&renderer,
|
|
&mut operation::black_box(&mut query),
|
|
);
|
|
match widget::Operation::finish(&query) {
|
|
operation::Outcome::Some(focused) => focused,
|
|
_ => panic!("missing focus query result"),
|
|
}
|
|
};
|
|
assert_eq!(focused, Some(ids[1].clone()));
|
|
drop(app.activity_navigation(bar, focused, Some(Named::ArrowDown)));
|
|
assert_eq!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.activities
|
|
.focus,
|
|
Some(ids[2].clone()),
|
|
"Tab focuses row two, so Down must focus row three"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_activities_fit_titles_and_keep_the_badge_adjacent() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
#[derive(Default)]
|
|
struct TextBounds(Vec<(String, Rectangle)>);
|
|
impl widget::Operation for TextBounds {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
|
visit(self);
|
|
}
|
|
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
|
self.0.push((text.into(), bounds));
|
|
}
|
|
}
|
|
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 = shell();
|
|
let id = app.outputs[0].bar;
|
|
let mut widths = Vec::new();
|
|
for count in [1, 2, 3] {
|
|
seed_activities(&mut app, count);
|
|
for activity in &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities {
|
|
activity.data.title = "Build".into();
|
|
}
|
|
drop(app.sync_hub_activity());
|
|
let width = app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size()[0];
|
|
widths.push(width);
|
|
drop(app.hub_hover(id, true));
|
|
assert_eq!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size()[0],
|
|
width
|
|
);
|
|
let mut view = app.activity_compact(id, 1., false);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, Size::new(width as f32, 44.)),
|
|
);
|
|
let mut labels = TextBounds::default();
|
|
view.as_widget_mut()
|
|
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
|
if count == 3 {
|
|
let title = labels
|
|
.0
|
|
.iter()
|
|
.rfind(|(text, _)| text == "Build")
|
|
.unwrap()
|
|
.1;
|
|
let badge = labels.0.iter().find(|(text, _)| text == "+1").unwrap().1;
|
|
assert!(
|
|
(badge.x - title.x - title.width - 8.).abs() < 1.,
|
|
"{:#?}",
|
|
labels.0
|
|
);
|
|
assert!(
|
|
(width as f32 - badge.x - badge.width - 14.).abs() < 1.,
|
|
"{:#?}",
|
|
labels.0
|
|
);
|
|
}
|
|
drop(view);
|
|
drop(app.hub_hover(id, false));
|
|
}
|
|
assert!(
|
|
widths[0] < 180.,
|
|
"short single activity should shrink: {widths:?}"
|
|
);
|
|
assert!(widths[0] < widths[1] && widths[1] < widths[2], "{widths:?}");
|
|
for activity in &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities {
|
|
activity.data.value =
|
|
ferese_activities::Value::Text("A much longer changing phase".into());
|
|
}
|
|
drop(app.sync_hub_activity());
|
|
assert_eq!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size()[0],
|
|
widths[2]
|
|
);
|
|
std::sync::Arc::make_mut(&mut app.activities.snapshot)
|
|
.limits
|
|
.show_badge = false;
|
|
drop(app.sync_hub_activity());
|
|
assert_eq!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size()[0],
|
|
widths[1],
|
|
"hiding the badge must remove its reserved width"
|
|
);
|
|
std::sync::Arc::make_mut(&mut app.activities.snapshot)
|
|
.limits
|
|
.show_badge = true;
|
|
drop(app.sync_hub_activity());
|
|
for activity in &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities {
|
|
activity.data.title =
|
|
"A very long title which must be capped in the compact strip".into();
|
|
}
|
|
drop(app.sync_hub_activity());
|
|
let wider = app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size()[0];
|
|
assert!(wider > widths[2] && wider < 450., "{wider}");
|
|
app.outputs[0].size = Some((240, 800));
|
|
drop(app.sync_hub_activity());
|
|
let width = app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size()[0] as f32;
|
|
assert!(width <= 240.);
|
|
let mut view = app.activity_compact(id, 1., false);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, Size::new(width, 44.)),
|
|
);
|
|
let mut labels = TextBounds::default();
|
|
view.as_widget_mut()
|
|
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
|
let badge = labels.0.iter().find(|(text, _)| text == "+1").unwrap().1;
|
|
assert!(
|
|
(width - badge.x - badge.width - 14.).abs() < 1.,
|
|
"badge must fit a narrow output: {:#?}",
|
|
labels.0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn activity_values_do_not_resize_and_hover_pins_survivors_and_dnd_overrides() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
seed_activities(&mut shell, 3);
|
|
drop(shell.sync_hub_activity());
|
|
let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap();
|
|
let width = runtime.borrow().hub.target_size()[0];
|
|
let first = runtime.borrow().activities.selection.visible.clone();
|
|
drop(shell.hub_hover(id, true));
|
|
std::sync::Arc::make_mut(&mut shell.activities.snapshot).activities[0]
|
|
.data
|
|
.priority = 255;
|
|
std::sync::Arc::make_mut(&mut shell.activities.snapshot).activities[2]
|
|
.data
|
|
.value = ferese_activities::Value::Text("A changed phase".into());
|
|
drop(shell.sync_hub_activity());
|
|
let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap();
|
|
assert_eq!(runtime.borrow().activities.selection.visible, first);
|
|
assert_eq!(runtime.borrow().hub.target_size()[0], width);
|
|
std::sync::Arc::make_mut(&mut shell.activities.snapshot)
|
|
.activities
|
|
.retain(|entry| entry.id != first[0]);
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.activities
|
|
.selection
|
|
.visible,
|
|
vec![first[1].clone()]
|
|
);
|
|
shell.notifications.dnd = true;
|
|
drop(shell.sync_hub_activity());
|
|
let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap();
|
|
assert_eq!(runtime.borrow().activities.selection.visible.len(), 1);
|
|
assert_eq!(runtime.borrow().activities.selection.hidden, 0);
|
|
drop(shell.hub_hover(id, false));
|
|
drop(shell.open_hub(id, status_ui::Menu::Activities));
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Expanded(Content::Activities))
|
|
);
|
|
}
|
|
#[test]
|
|
fn shared_activity_item_and_popup_work_in_flat_floating_and_adaptive_panels() {
|
|
for style in [PanelStyle::Flat, PanelStyle::Floating, PanelStyle::Adaptive] {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
seed_activities(&mut shell, 2);
|
|
shell.config.panels[0].style = style;
|
|
shell.outputs[0]
|
|
.runtime
|
|
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
|
|
drop(shell.sync_hub_activity());
|
|
assert!(shell.activity_available(id));
|
|
assert!(shell.outputs[0].runtime.hub.is_none());
|
|
let item = shell.config.panels[0]
|
|
.item(&ferese_panel::ItemId("test-activities".into()))
|
|
.unwrap();
|
|
assert!(
|
|
shell
|
|
.view_activity_item(
|
|
id,
|
|
item,
|
|
ferese_panel::Representation::Compact,
|
|
false,
|
|
Some(160.),
|
|
28.
|
|
)
|
|
.1
|
|
);
|
|
let anchor = crate::status_ui::PopoverAnchor {
|
|
parent: id,
|
|
panel: shell.config.panels[0].id.clone(),
|
|
item: Some(item.id.clone()),
|
|
rectangle: cosmic::iced::Rectangle {
|
|
x: 20,
|
|
y: 0,
|
|
width: 160,
|
|
height: 28,
|
|
},
|
|
};
|
|
drop(shell.open_menu(status_ui::Menu::Activities, anchor));
|
|
assert_eq!(
|
|
shell.menu.as_ref().unwrap().kind,
|
|
status_ui::Menu::Activities
|
|
);
|
|
assert!(
|
|
shell
|
|
.activity_key(
|
|
shell.menu.as_ref().unwrap().id,
|
|
cosmic::iced::keyboard::key::Named::ArrowDown
|
|
)
|
|
.is_some()
|
|
);
|
|
shell.activities.snapshot = std::sync::Arc::new(Default::default());
|
|
drop(shell.sync_hub_activity());
|
|
assert!(!shell.activity_available(id));
|
|
}
|
|
}
|
|
#[test]
|
|
fn empty_activity_updates_preserve_the_measured_idle_width() {
|
|
let mut app = shell();
|
|
seed_activities(&mut app, 0);
|
|
drop(app.sync_hub_activity());
|
|
{
|
|
let mut runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut();
|
|
let available = runtime.available;
|
|
// Layout measures the whole ordinary idle row, including workspace dots.
|
|
runtime.idle_content_width = Some(224.);
|
|
runtime.hub.retarget_idle(224., available).unwrap();
|
|
runtime.hub.advance(Duration::ZERO, true);
|
|
}
|
|
for _ in 0..3 {
|
|
drop(app.sync_hub_activity());
|
|
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
|
assert_eq!(runtime.idle_content_width, Some(224.));
|
|
assert_eq!(runtime.hub.target_size()[0], 224.);
|
|
assert!(
|
|
!runtime.hub.active(),
|
|
"status refresh restarted the idle morph"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn activity_updates_keep_the_binding_indicator_as_the_only_geometry_owner() {
|
|
let mut app = shell();
|
|
let bar = app.outputs[0].bar;
|
|
app.snapshot.binding_mode = Some("resize".into());
|
|
drop(app.binding_mode_transition(false));
|
|
let (allocation, target) = {
|
|
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
|
(runtime.allocation, runtime.hub.target_size())
|
|
};
|
|
seed_activities(&mut app, 3);
|
|
drop(app.sync_hub_activity());
|
|
{
|
|
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
|
assert_eq!(
|
|
runtime.activities.selection.visible.len(),
|
|
2,
|
|
"activity data must still update"
|
|
);
|
|
assert_eq!(
|
|
runtime.hub.target_size(),
|
|
target,
|
|
"hidden activity content cannot retarget the hub"
|
|
);
|
|
assert_eq!(runtime.allocation, allocation);
|
|
assert!(!runtime.hub.active());
|
|
}
|
|
app.snapshot.binding_mode = None;
|
|
drop(app.binding_mode_transition(true));
|
|
assert_ne!(
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size(),
|
|
target
|
|
);
|
|
drop(app.open_hub(bar, status_ui::Menu::Activities));
|
|
assert_eq!(app.hub_mode(bar), Some(Mode::Expanded(Content::Activities)));
|
|
}
|
|
|
|
#[test]
|
|
fn progress_updates_do_not_start_a_second_hub_animation() {
|
|
let mut app = shell();
|
|
let bar = app.outputs[0].bar;
|
|
seed_activities(&mut app, 1);
|
|
drop(app.sync_hub_activity());
|
|
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap();
|
|
runtime
|
|
.borrow_mut()
|
|
.activities
|
|
.advance(Duration::ZERO, true);
|
|
assert!(!runtime.borrow().activities.active());
|
|
std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0]
|
|
.data
|
|
.value = ferese_activities::Value::Progress(0.75);
|
|
drop(app.sync_hub_activity());
|
|
assert!(
|
|
!app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.activities
|
|
.active()
|
|
);
|
|
assert!(app.activity_available(bar));
|
|
}
|
|
#[test]
|
|
fn shell_rendering_configuration_is_instance_owned() {
|
|
let mut animated = shell();
|
|
let mut snap = shell();
|
|
let original = animated.render;
|
|
let mut config = snap.config.clone();
|
|
config.font_family = Some("monospace".into());
|
|
config.theme.material_radius = 3.;
|
|
config.animations.reduced_motion = true;
|
|
drop(snap.apply_config(config));
|
|
assert_eq!(animated.render.font, original.font);
|
|
assert_ne!(snap.render.font, animated.render.font);
|
|
assert_eq!(animated.render.radius(20.), original.radius(20.));
|
|
assert_eq!(snap.render.radius(20.), 3.);
|
|
for instance in [&mut animated, &mut snap] {
|
|
drop(instance.open_hub(instance.outputs[0].bar, status_ui::Menu::Calendar));
|
|
}
|
|
let mut a = animated.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow_mut();
|
|
let mut b = snap.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut();
|
|
let now = Instant::now() + Duration::from_millis(50);
|
|
assert_ne!(a.sample(now).geometry.bounds.height, a.hub.target_size()[1]);
|
|
assert_eq!(b.sample(now).geometry.bounds.height, b.hub.target_size()[1]);
|
|
assert!(a.motion.motion_enabled());
|
|
assert!(!b.motion.motion_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn closing_and_repeated_feedback_preserve_the_outgoing_content() {
|
|
use transition::Event as Transition;
|
|
let mut runtime = Runtime::new(&Panel::default());
|
|
let area = [1000., 700.];
|
|
runtime.transition(
|
|
Transition::Hover {
|
|
entered: true,
|
|
enabled: true,
|
|
peek: (Peek::Media, 240.),
|
|
priority: None,
|
|
},
|
|
area,
|
|
);
|
|
runtime.hub.advance(Duration::from_secs(1), false);
|
|
runtime.transition(Transition::Close, area);
|
|
assert_eq!(
|
|
runtime.previous.peek,
|
|
Peek::Media,
|
|
"the closing fade keeps its media content"
|
|
);
|
|
runtime.hub.advance(Duration::from_secs(1), false);
|
|
|
|
let token = Instant::now();
|
|
runtime.transition(Transition::Toast { notice: 7, token }, area);
|
|
runtime.hub.advance(Duration::from_secs(1), false);
|
|
runtime.transition(Transition::ToastExpired(token), area);
|
|
assert_eq!(
|
|
runtime.previous.toast,
|
|
Some(7),
|
|
"expiry keeps the outgoing toast for its fade"
|
|
);
|
|
runtime.hub.advance(Duration::from_secs(1), false);
|
|
|
|
runtime.transition(
|
|
Transition::Hover {
|
|
entered: true,
|
|
enabled: true,
|
|
peek: (Peek::Media, 240.),
|
|
priority: None,
|
|
},
|
|
area,
|
|
);
|
|
runtime.hub.advance(Duration::from_secs(1), false);
|
|
runtime.transition(Transition::Workspaces(token), area);
|
|
runtime.hub.advance(Duration::from_millis(60), false);
|
|
runtime.transition(
|
|
Transition::Workspaces(token + Duration::from_millis(1)),
|
|
area,
|
|
);
|
|
assert_eq!(
|
|
runtime.previous.peek,
|
|
Peek::Media,
|
|
"repeated feedback must not replace the fading content"
|
|
);
|
|
assert_eq!(runtime.peek, Peek::Workspaces);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_transient_expiry_cannot_close_new_content() {
|
|
use transition::Event as Transition;
|
|
let panel = Panel::default();
|
|
let mut runtime = Runtime::new(&panel);
|
|
let area = [1000., 700.];
|
|
let first = Instant::now();
|
|
let second = first + Duration::from_millis(1);
|
|
runtime.transition(
|
|
Transition::Toast {
|
|
notice: 1,
|
|
token: first,
|
|
},
|
|
area,
|
|
);
|
|
runtime.transition(
|
|
Transition::Toast {
|
|
notice: 2,
|
|
token: second,
|
|
},
|
|
area,
|
|
);
|
|
assert!(!runtime.transition(Transition::ToastExpired(first), area));
|
|
assert_eq!(runtime.transient.toast(), Some(2));
|
|
runtime.transition(Transition::Open(status_ui::Menu::Calendar), area);
|
|
assert!(!runtime.transition(Transition::ToastExpired(second), area));
|
|
assert_eq!(runtime.hub.mode(), Mode::Expanded(Content::Calendar));
|
|
runtime.transition(Transition::Close, area);
|
|
runtime.transition(Transition::Workspaces(first), area);
|
|
runtime.transition(
|
|
Transition::Activity {
|
|
priority: Some(Peek::Recording),
|
|
peek: (Peek::Recording, 240.),
|
|
},
|
|
area,
|
|
);
|
|
assert!(!runtime.transition(Transition::WorkspacesExpired(first), area));
|
|
assert_eq!(runtime.peek, Peek::Recording);
|
|
assert!(matches!(runtime.transient, Transient::None));
|
|
assert!(!runtime.transition(
|
|
Transition::Toast {
|
|
notice: 3,
|
|
token: second
|
|
},
|
|
area
|
|
));
|
|
assert!(!runtime.transition(Transition::Workspaces(second), area));
|
|
}
|
|
|
|
#[test]
|
|
fn recording_and_low_battery_take_priority_without_interrupting_open_panels() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
std::sync::Arc::make_mut(&mut shell.media.snapshot).selected =
|
|
Some(ferese_ipc::media::Player {
|
|
title: "Music".into(),
|
|
status: ferese_ipc::media::Playback::Playing,
|
|
..Default::default()
|
|
});
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(shell.hub_peek_content(id).0, Peek::Media);
|
|
shell.status.battery = Some(crate::status::Battery {
|
|
percent: 12,
|
|
status: "Discharging".into(),
|
|
});
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(shell.hub_priority(), Some(Peek::Battery));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Battery
|
|
);
|
|
shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now());
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(shell.hub_priority(), Some(Peek::Recording));
|
|
assert_eq!(
|
|
shell.hub_low_battery(),
|
|
Some(12),
|
|
"battery warning must remain available beside the timer"
|
|
);
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.peek_width,
|
|
280.
|
|
);
|
|
drop(shell.hub_notice(99));
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Peek),
|
|
"notifications cannot hide recording"
|
|
);
|
|
shell.status.battery.as_mut().unwrap().status = "Charging".into();
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(shell.hub_low_battery(), None);
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.peek_width,
|
|
240.
|
|
);
|
|
shell.recorder.state = crate::recording::State::Saving;
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(shell.hub_priority(), Some(Peek::Recording));
|
|
shell.recorder.state = crate::recording::State::Idle;
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Media
|
|
);
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now());
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar)));
|
|
drop(shell.close_hub(id));
|
|
assert_eq!(shell.hub_priority(), Some(Peek::Recording));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
}
|
|
|
|
#[test]
|
|
fn low_battery_respects_threshold_charging_and_config_reload() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
shell.config.status.low_battery_threshold = 20;
|
|
for (percent, status, expected) in [
|
|
(20, "Discharging", false),
|
|
(19, "Discharging", true),
|
|
(0, "Empty", true),
|
|
(10, "Pending discharge", true),
|
|
(10, "Charging", false),
|
|
(10, "Pending charge", false),
|
|
(10, "Full", false),
|
|
(10, "Unknown", false),
|
|
] {
|
|
shell.status.battery = Some(crate::status::Battery {
|
|
percent,
|
|
status: status.into(),
|
|
});
|
|
assert_eq!(
|
|
shell.hub_low_battery().is_some(),
|
|
expected,
|
|
"{percent}%, {status}"
|
|
);
|
|
}
|
|
shell.status.battery = Some(crate::status::Battery {
|
|
percent: 15,
|
|
status: "Discharging".into(),
|
|
});
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Battery
|
|
);
|
|
let mut config = shell.config.clone();
|
|
config.status.low_battery_threshold = 10;
|
|
drop(shell.apply_config(config));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Date
|
|
);
|
|
shell.recorder.state = crate::recording::State::Selecting;
|
|
assert_eq!(
|
|
shell.hub_priority(),
|
|
None,
|
|
"selection is not an active recording"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn priority_status_supersedes_workspace_feedback_and_toasts() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
shell.snapshot.outputs = vec![crate::control::OutputSnapshot {
|
|
id: 4,
|
|
name: "test".into(),
|
|
active_workspace: 1,
|
|
focused: true,
|
|
}];
|
|
shell.snapshot.workspaces = (1..=3)
|
|
.map(|id| crate::control::WorkspaceSnapshot {
|
|
id,
|
|
name: id.to_string(),
|
|
output: Some(4),
|
|
index: id as u32,
|
|
window_count: 1,
|
|
visible: id == 1,
|
|
focused: id == 1,
|
|
})
|
|
.collect();
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
shell.snapshot.outputs[0].active_workspace = 2;
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Peek));
|
|
shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now());
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
assert!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.transient
|
|
.workspace_token()
|
|
.is_none()
|
|
);
|
|
shell.snapshot.outputs[0].active_workspace = 3;
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
shell.recorder.state = crate::recording::State::Idle;
|
|
drop(shell.sync_hub_activity());
|
|
drop(shell.set_hub_mode(id, Mode::Toast, None));
|
|
shell.status.battery = Some(crate::status::Battery {
|
|
percent: 1,
|
|
status: "Discharging".into(),
|
|
});
|
|
drop(shell.sync_hub_activity());
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_strip_is_output_local_and_does_not_interrupt_open_content() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
shell.snapshot.outputs = vec![crate::control::OutputSnapshot {
|
|
id: 4,
|
|
name: "test".into(),
|
|
active_workspace: 9,
|
|
focused: true,
|
|
}];
|
|
shell.snapshot.workspaces = [(9, 4, 0), (10, 7, 10), (11, 4, 0)]
|
|
.into_iter()
|
|
.map(
|
|
|(workspace, output, count)| crate::control::WorkspaceSnapshot {
|
|
id: workspace,
|
|
name: workspace.to_string(),
|
|
output: Some(output),
|
|
index: workspace as u32,
|
|
window_count: count,
|
|
visible: workspace == 9,
|
|
focused: workspace == 9,
|
|
},
|
|
)
|
|
.collect();
|
|
// A bar can be created after the initial compositor snapshot arrives.
|
|
drop(shell.update(Message::PanelScale(id, 1.25)));
|
|
let width = shell.hub_workspace_width(id);
|
|
assert!(width > 0., "the active empty workspace remains visible");
|
|
shell.snapshot.workspaces[1].window_count = 20;
|
|
drop(shell.update_workspace_ui());
|
|
assert_eq!(
|
|
shell.hub_workspace_width(id),
|
|
width,
|
|
"another output cannot change this strip"
|
|
);
|
|
{
|
|
let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut();
|
|
runtime.hub.advance(Duration::ZERO, true);
|
|
assert_eq!(
|
|
runtime.hub.target_size()[0],
|
|
shell.config.panels[0].morph.idle_width + width + 10.
|
|
);
|
|
assert_eq!(runtime.hub.sample().opacity, 1.);
|
|
}
|
|
drop(shell.hub_hover(id, true));
|
|
{
|
|
let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
|
assert_eq!(runtime.hub.target_size()[0], runtime.peek_width);
|
|
}
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
let before = shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.sample();
|
|
shell.snapshot.workspaces[2].window_count = 1;
|
|
drop(shell.update_workspace_ui());
|
|
assert!(shell.hub_workspace_width(id) > width);
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.sample(),
|
|
before
|
|
);
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar)));
|
|
drop(shell.open_hub_overview(id));
|
|
assert!(!shell.overview_active);
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Expanded(Content::Workspaces))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_tile_selection_closes_without_confirmation_and_hover_resets() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
shell.snapshot.outputs = vec![crate::control::OutputSnapshot {
|
|
id: 4,
|
|
name: "test".into(),
|
|
active_workspace: 1,
|
|
focused: true,
|
|
}];
|
|
shell.snapshot.workspaces = (1..=3)
|
|
.map(|id| crate::control::WorkspaceSnapshot {
|
|
id,
|
|
name: id.to_string(),
|
|
output: Some(if id == 3 { 9 } else { 4 }),
|
|
index: id as u32,
|
|
window_count: 0,
|
|
visible: id == 1,
|
|
focused: id == 1,
|
|
})
|
|
.collect();
|
|
drop(shell.sync_hub_workspaces());
|
|
drop(shell.open_hub_overview(id));
|
|
drop(shell.hub_workspace_hover(id, Some(2)));
|
|
drop(shell.hub_workspace_hover(id, None));
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.workspace_hover,
|
|
None
|
|
);
|
|
drop(shell.hub_workspace_select(id, 3));
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Expanded(Content::Workspaces)),
|
|
"another output must be rejected"
|
|
);
|
|
drop(shell.hub_workspace_select(id, 2));
|
|
shell.snapshot.outputs[0].active_workspace = 2;
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
assert!(!shell.overview_active);
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.workspace_selection_pending,
|
|
None
|
|
);
|
|
shell.snapshot.outputs[0].active_workspace = 1;
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Peek),
|
|
"later keyboard switches still get feedback"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_feedback_updates_in_place_and_restores_hover_content() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
shell.snapshot.outputs = vec![crate::control::OutputSnapshot {
|
|
id: 4,
|
|
name: "test".into(),
|
|
active_workspace: 1,
|
|
focused: true,
|
|
}];
|
|
shell.snapshot.workspaces = (1..=3)
|
|
.map(|id| crate::control::WorkspaceSnapshot {
|
|
id,
|
|
name: id.to_string(),
|
|
output: Some(4),
|
|
index: id as u32,
|
|
window_count: 1,
|
|
visible: id == 1,
|
|
focused: id == 1,
|
|
})
|
|
.collect();
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Idle),
|
|
"initial snapshot must not flash"
|
|
);
|
|
drop(shell.hub_hover(id, true));
|
|
shell.snapshot.outputs[0].active_workspace = 2;
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
let first = {
|
|
let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut();
|
|
assert_eq!(runtime.peek, Peek::Workspaces);
|
|
runtime.hub.advance(Duration::ZERO, true);
|
|
runtime.transient.workspace_token().unwrap()
|
|
};
|
|
shell.snapshot.outputs[0].active_workspace = 3;
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
let latest = {
|
|
let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
|
assert_eq!(
|
|
runtime.hub.sample().opacity,
|
|
1.,
|
|
"repeated switches must not restart the fade"
|
|
);
|
|
runtime.transient.workspace_token().unwrap()
|
|
};
|
|
assert_ne!(first, latest);
|
|
drop(shell.hub_workspace_feedback_expired(id, first));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Workspaces
|
|
);
|
|
drop(shell.hub_workspace_feedback_expired(id, latest));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Peek));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Date
|
|
);
|
|
drop(shell.hub_hover(id, false));
|
|
shell.snapshot.outputs[0].active_workspace = 1;
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
let revision = shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.transient
|
|
.workspace_token()
|
|
.unwrap();
|
|
drop(shell.hub_workspace_feedback_expired(id, revision));
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Idle),
|
|
"keyboard feedback returns to idle"
|
|
);
|
|
shell.snapshot.outputs[0].active_workspace = 2;
|
|
drop(shell.sync_hub_workspace_feedback(true));
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Idle),
|
|
"overview selection must not flash"
|
|
);
|
|
drop(shell.hub_hover(id, true));
|
|
shell.snapshot.outputs[0].active_workspace = 3;
|
|
drop(shell.sync_hub_workspace_feedback(false));
|
|
drop(shell.hub_hover(id, false));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Date,
|
|
"leaving early must clear temporary workspace content"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_pointer_reentry_always_peeks_without_resizing_the_surface() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
let allocation = shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.allocation;
|
|
for _ in 0..20 {
|
|
drop(shell.handle_event(Event::Mouse(mouse::Event::CursorEntered), id));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Peek));
|
|
drop(shell.handle_event(Event::Mouse(mouse::Event::CursorLeft), id));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut();
|
|
runtime.hub.advance(Duration::from_secs(1), false);
|
|
assert_eq!(runtime.allocation, allocation);
|
|
}
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
drop(shell.handle_event(Event::Mouse(mouse::Event::CursorLeft), id));
|
|
drop(shell.handle_event(Event::Mouse(mouse::Event::CursorEntered), id));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar)));
|
|
}
|
|
|
|
#[test]
|
|
fn hover_only_peeks_and_respects_preference_and_visibility() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Peek));
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Peek));
|
|
drop(shell.hub_hover(id, false));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
drop(shell.set_hub_mode(id, Mode::Toast, None));
|
|
drop(shell.hub_hover(id, true));
|
|
drop(shell.hub_hover(id, false));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Toast));
|
|
drop(shell.close_hub(id));
|
|
let mut config = shell.config.clone();
|
|
config.panels[0].morph.peek_on_hover = false;
|
|
drop(shell.apply_config(config));
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
let mut config = shell.config.clone();
|
|
config.panels[0].morph.peek_on_hover = true;
|
|
drop(shell.apply_config(config));
|
|
shell.snapshot.outputs.push(crate::control::OutputSnapshot {
|
|
id: 1,
|
|
name: "test".into(),
|
|
active_workspace: 7,
|
|
focused: true,
|
|
});
|
|
shell.snapshot.windows.push(crate::control::WindowSnapshot {
|
|
id: 42,
|
|
workspace: 7,
|
|
app_id: "test".into(),
|
|
title: "Test".into(),
|
|
focused: true,
|
|
urgent: false,
|
|
fullscreen: true,
|
|
maximized: false,
|
|
floating: false,
|
|
geometry: None,
|
|
});
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
}
|
|
|
|
#[test]
|
|
fn peek_category_and_width_stay_fixed_until_the_next_hover() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Date
|
|
);
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size(),
|
|
[200., 44.]
|
|
);
|
|
std::sync::Arc::make_mut(&mut shell.media.snapshot).selected =
|
|
Some(ferese_ipc::media::Player {
|
|
title: "A long track title that needs the wider preview".into(),
|
|
status: ferese_ipc::media::Playback::Paused,
|
|
..Default::default()
|
|
});
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Date
|
|
);
|
|
drop(shell.hub_hover(id, false));
|
|
drop(shell.hub_hover(id, true));
|
|
let size = shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size();
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Media
|
|
);
|
|
assert!((200. ..=280.).contains(&size[0]));
|
|
assert_eq!(size[1], 44.);
|
|
std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = None;
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Media
|
|
);
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.target_size(),
|
|
size
|
|
);
|
|
drop(shell.hub_hover(id, false));
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek,
|
|
Peek::Date
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn date_click_opens_workspaces_in_idle_and_peek_regardless_of_last_tab() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
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 = shell();
|
|
let id = app.outputs[0].bar;
|
|
app.snapshot.windows.clear();
|
|
app.snapshot.workspaces.clear();
|
|
app.status.battery = None;
|
|
std::sync::Arc::make_mut(&mut app.media.snapshot).selected = None;
|
|
for last in [status_ui::Menu::Calendar, status_ui::Menu::Activities] {
|
|
app.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow_mut()
|
|
.last_menu = last;
|
|
for mode in [Mode::Idle, Mode::Peek] {
|
|
let mut view = app.hub_content(id, mode, 1., None, true, false);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let viewport = Rectangle::with_size(Size::new(280., 44.));
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(viewport.size(), viewport.size()),
|
|
);
|
|
let mut messages = Vec::new();
|
|
for event in [
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
] {
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&Event::Mouse(event),
|
|
Layout::new(&node),
|
|
mouse::Cursor::Available(viewport.center()),
|
|
&renderer,
|
|
&mut clipboard::Null,
|
|
&mut Shell::new(&mut messages),
|
|
&viewport,
|
|
);
|
|
}
|
|
let opened: Vec<_> = messages
|
|
.iter()
|
|
.filter_map(|message| match message {
|
|
cosmic::Action::App(Message::HubOpen(_, menu)) => Some(*menu),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
opened,
|
|
vec![status_ui::Menu::Workspaces],
|
|
"{mode:?}, last tab {last:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn collapse_remembers_the_last_selected_panel() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.last_menu,
|
|
status_ui::Menu::Workspaces
|
|
);
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
drop(shell.close_hub(id));
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.last_menu,
|
|
status_ui::Menu::Calendar
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn header_shortcuts_fit_the_settled_width_and_respect_item_visibility() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
for (playback, counts) in [
|
|
(None, [0, 2, 2]),
|
|
(Some(ferese_ipc::media::Playback::Playing), [0, 2, 2]),
|
|
(Some(ferese_ipc::media::Playback::Paused), [0, 2, 2]),
|
|
] {
|
|
std::sync::Arc::make_mut(&mut shell.media.snapshot).selected =
|
|
playback.map(|status| ferese_ipc::media::Player {
|
|
status,
|
|
..Default::default()
|
|
});
|
|
for (width, count) in [240., 340., 400.].into_iter().zip(counts) {
|
|
shell.config.panels[0].morph.expanded_width = width;
|
|
let shortcuts = shell.hub_shortcuts(id);
|
|
assert_eq!(shortcuts.len(), count);
|
|
assert!(!shortcuts.contains(&ItemKind::NightLight));
|
|
assert!(!shortcuts.contains(&ItemKind::Network));
|
|
assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 1);
|
|
assert!(!shortcuts.contains(&ItemKind::DisplayMode));
|
|
assert!(!shortcuts.contains(&ItemKind::Audio));
|
|
assert!(
|
|
!shortcuts
|
|
.iter()
|
|
.any(|kind| matches!(kind, ItemKind::Battery { .. }))
|
|
);
|
|
}
|
|
}
|
|
let before = shell.hub_shortcuts(id);
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
assert_eq!(
|
|
shell.hub_shortcuts(id),
|
|
before,
|
|
"shortcuts must not shuffle during the spring"
|
|
);
|
|
let panel = &mut shell.config.panels[0];
|
|
for item in [&mut panel.start, &mut panel.center, &mut panel.end]
|
|
.into_iter()
|
|
.flat_map(|zone| &mut zone.groups)
|
|
.flat_map(|group| &mut group.items)
|
|
{
|
|
if item.kind == ItemKind::Overview {
|
|
item.visible = false;
|
|
}
|
|
if matches!(item.kind, ItemKind::DisplayMode | ItemKind::Recording) {
|
|
item.overflow = ferese_panel::OverflowPolicy::Always;
|
|
}
|
|
}
|
|
let shortcuts = shell.hub_shortcuts(id);
|
|
assert!(!shortcuts.contains(&ItemKind::Overview));
|
|
assert!(!shortcuts.contains(&ItemKind::DisplayMode));
|
|
assert!(!shortcuts.contains(&ItemKind::Recording));
|
|
shell.outputs[0].size = Some((200, 600));
|
|
assert!(
|
|
shell.hub_shortcuts(id).is_empty(),
|
|
"small outputs must keep optional controls in the grid"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opening_and_switching_panels_do_not_resize_again_after_settlement() {
|
|
let panel = Panel::from_preset(PanelStyle::Morph);
|
|
let mut runtime = Runtime::new(&panel);
|
|
for menu in [
|
|
status_ui::Menu::Media,
|
|
status_ui::Menu::Calendar,
|
|
status_ui::Menu::Notifications,
|
|
status_ui::Menu::Overflow,
|
|
] {
|
|
runtime.retarget(Mode::Expanded(content(menu)), [1000., 700.], Some(menu));
|
|
let allocation = runtime.allocation;
|
|
for _ in 0..200 {
|
|
runtime.hub.advance(Duration::from_millis(8), false);
|
|
}
|
|
assert!(!runtime.hub.active());
|
|
assert_eq!(runtime.allocation, allocation);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn attached_shoulders_fit_the_surface_through_morphs_and_reversals() {
|
|
let mut panel = Panel::from_preset(PanelStyle::Morph);
|
|
panel.morph.attached = true;
|
|
for edge in [Edge::Top, Edge::Bottom] {
|
|
panel.edge = edge;
|
|
let mut runtime = Runtime::new(&panel);
|
|
for mode in [
|
|
Mode::Peek,
|
|
Mode::Expanded(Content::Calendar),
|
|
Mode::Idle,
|
|
Mode::Peek,
|
|
Mode::Idle,
|
|
] {
|
|
runtime.retarget(mode, [800., 600.], None);
|
|
let allocation = runtime.allocation;
|
|
for _ in 0..18 {
|
|
runtime.hub.advance(Duration::from_millis(8), false);
|
|
let sample = runtime.hub.sample();
|
|
let bounds = sample.geometry.bounds;
|
|
let scene = ferese_panel::scene::derive(
|
|
ferese_panel::scene::Parameters {
|
|
radius: Some(ferese_panel::shape::AnchoredRadii(
|
|
sample.geometry.radii.map(|r| r as f32),
|
|
)),
|
|
..panel.shape_parameters()
|
|
},
|
|
ferese_layout::Rect::new(
|
|
(f64::from(allocation[0]) - bounds.width) / 2.,
|
|
0.,
|
|
bounds.width,
|
|
bounds.height,
|
|
),
|
|
&[],
|
|
ferese_panel::shape::PixelGrid::try_new(1.5).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(scene.paint.len(), 3);
|
|
for primitive in scene.paint {
|
|
assert!(primitive.bounds.x >= -0.01);
|
|
assert!(
|
|
primitive.bounds.x + primitive.bounds.width
|
|
<= f64::from(allocation[0]) + 0.01
|
|
);
|
|
}
|
|
assert_eq!(
|
|
runtime.allocation, allocation,
|
|
"no resize during settlement"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opening_closing_and_reopening_retain_the_native_surface() {
|
|
let panel = Panel::from_preset(PanelStyle::Morph);
|
|
let mut runtime = Runtime::new(&panel);
|
|
assert_eq!(runtime.allocation, [286, 49]);
|
|
runtime.retarget(
|
|
Mode::Expanded(Content::Calendar),
|
|
[1000., 700.],
|
|
Some(status_ui::Menu::Calendar),
|
|
);
|
|
let expanded_allocation = runtime.allocation;
|
|
assert!(expanded_allocation[0] > 340 && expanded_allocation[1] > 230);
|
|
runtime.hub.advance(Duration::from_millis(50), false);
|
|
let before = runtime.hub.sample().geometry;
|
|
runtime.retarget(Mode::Idle, [1000., 700.], None);
|
|
// A reversal never commits a smaller buffer underneath an unfinished shape.
|
|
assert_eq!(runtime.allocation, expanded_allocation);
|
|
assert!(runtime.hub.sample().geometry.bounds.width >= before.bounds.width);
|
|
runtime.hub.advance(Duration::from_secs(2), false);
|
|
runtime.hub.advance(Duration::from_millis(120), false);
|
|
assert_eq!(runtime.allocation, expanded_allocation);
|
|
assert!(!runtime.hub.active());
|
|
runtime.retarget(
|
|
Mode::Expanded(Content::Calendar),
|
|
[1000., 700.],
|
|
Some(status_ui::Menu::Calendar),
|
|
);
|
|
assert_eq!(runtime.allocation, expanded_allocation);
|
|
}
|
|
|
|
#[test]
|
|
fn hover_leave_cannot_close_an_expanded_hub_and_reload_clears_its_owner() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
let reservation = shell.config.panels[0].exclusive_zone(false);
|
|
drop(shell.hub_hover(id, true));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Peek));
|
|
drop(shell.hub_hover(id, false));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
drop(shell.update(Message::HubOpen(id, status_ui::Menu::Calendar)));
|
|
assert!(shell.menu.is_none(), "expanded hub owns no popup surface");
|
|
drop(shell.hub_hover(id, false));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar)));
|
|
assert_eq!(shell.config.panels[0].exclusive_zone(false), reservation);
|
|
drop(shell.update(Message::HubClose(id)));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
let mut config = shell.config.clone();
|
|
config.panels[0].style = PanelStyle::Floating;
|
|
drop(shell.apply_config(config));
|
|
assert_eq!(shell.hub_mode(id), None);
|
|
assert_eq!(shell.config.panels[0].style, PanelStyle::Floating);
|
|
}
|
|
|
|
#[test]
|
|
fn fullscreen_collapses_immediately_and_old_toast_deadlines_do_not_close_new_content() {
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
drop(shell.set_hub_mode(id, Mode::Toast, None));
|
|
let old = shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.revision;
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
drop(shell.hub_toast_expired(id, old));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar)));
|
|
drop(shell.suspend_hub(id));
|
|
let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
|
assert_eq!(runtime.hub.mode(), Mode::Idle);
|
|
assert_eq!(runtime.allocation, [286, 49]);
|
|
assert!(!runtime.hub.active());
|
|
}
|
|
|
|
#[test]
|
|
fn losing_layer_focus_collapses_only_the_expanded_hub_on_that_output() {
|
|
use cosmic::iced::event::{PlatformSpecific, wayland};
|
|
use wayland_client::Proxy;
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
let (socket, peer) = std::os::unix::net::UnixStream::pair().unwrap();
|
|
let connection = wayland_client::Connection::from_socket(socket).unwrap();
|
|
let surface = wayland_client::protocol::wl_surface::WlSurface::inert(
|
|
connection.backend().downgrade(),
|
|
);
|
|
drop(peer);
|
|
let unfocused =
|
|
|id| {
|
|
cosmic::iced::Event::PlatformSpecific(PlatformSpecific::Wayland(
|
|
wayland::Event::Layer(wayland::LayerEvent::Unfocused, surface.clone(), id),
|
|
))
|
|
};
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
let other = window::Id::unique();
|
|
drop(shell.handle_event(unfocused(other), other));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar)));
|
|
drop(shell.handle_event(unfocused(id), id));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
assert_eq!(
|
|
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().menu,
|
|
None
|
|
);
|
|
drop(shell.hub_hover(id, true));
|
|
drop(shell.handle_event(unfocused(id), id));
|
|
assert_eq!(
|
|
shell.hub_mode(id),
|
|
Some(Mode::Peek),
|
|
"focus changes must not interrupt hover peeks"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_leave_closes_peek_and_escape_closes_expanded_content() {
|
|
use cosmic::iced::keyboard::{Event as KeyEvent, Key, Location, Modifiers, key};
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
drop(shell.hub_hover(id, true));
|
|
drop(shell.handle_event(Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), id));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
drop(shell.handle_event(Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), id));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar)));
|
|
drop(shell.handle_event(
|
|
Event::Keyboard(KeyEvent::KeyPressed {
|
|
key: Key::Named(key::Named::Escape),
|
|
modified_key: Key::Named(key::Named::Escape),
|
|
physical_key: key::Physical::Code(key::Code::Escape),
|
|
location: Location::Standard,
|
|
modifiers: Modifiers::empty(),
|
|
text: None,
|
|
repeat: false,
|
|
}),
|
|
id,
|
|
));
|
|
assert_eq!(shell.hub_mode(id), Some(Mode::Idle));
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "writes Morph review captures to /tmp/ferese-hub-review"]
|
|
fn render_hub_review_states() {
|
|
use cosmic::iced::advanced::renderer::{Headless, Renderer as _};
|
|
let backend = std::env::var("FERESE_REVIEW_BACKEND").unwrap_or_else(|_| "wgpu".into());
|
|
let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
|
cosmic::iced::Font::default(),
|
|
cosmic::iced::Pixels(14.),
|
|
Some(&backend),
|
|
))
|
|
.unwrap();
|
|
std::fs::create_dir_all("/tmp/ferese-hub-review").unwrap();
|
|
let mut shell = shell();
|
|
let id = shell.outputs[0].bar;
|
|
std::sync::Arc::make_mut(&mut shell.media.snapshot).selected =
|
|
Some(ferese_ipc::media::Player {
|
|
title: "Losing My Mind".into(),
|
|
artist: "London Grammar".into(),
|
|
status: ferese_ipc::media::Playback::Playing,
|
|
length_us: Some(240_000_000),
|
|
position_us: Some(80_000_000),
|
|
sampled_at_us: ferese_ipc::media::now_us(),
|
|
can_control: true,
|
|
can_pause: true,
|
|
can_next: true,
|
|
can_previous: true,
|
|
can_seek: true,
|
|
track_id: Some("/track/1".into()),
|
|
..Default::default()
|
|
});
|
|
shell.snapshot.outputs = vec![crate::control::OutputSnapshot {
|
|
id: 4,
|
|
name: "test".into(),
|
|
active_workspace: 1,
|
|
focused: true,
|
|
}];
|
|
shell.snapshot.workspaces = (1..=5)
|
|
.map(|id| crate::control::WorkspaceSnapshot {
|
|
id,
|
|
name: format!("Desk {id}"),
|
|
output: Some(4),
|
|
index: id as u32,
|
|
window_count: (id % 3) as u32,
|
|
visible: id == 1,
|
|
focused: id == 1,
|
|
})
|
|
.collect();
|
|
shell.snapshot.windows = shell
|
|
.snapshot
|
|
.workspaces
|
|
.iter()
|
|
.flat_map(|workspace| {
|
|
(0..workspace.window_count).map(move |n| crate::control::WindowSnapshot {
|
|
id: workspace.id * 10 + u64::from(n),
|
|
workspace: workspace.id,
|
|
app_id: "test".into(),
|
|
title: format!("Window {}", n + 1),
|
|
geometry: Some([
|
|
f64::from(n) * 650.,
|
|
0.,
|
|
if n == 0 { 640. } else { 320. },
|
|
720.,
|
|
]),
|
|
focused: false,
|
|
urgent: false,
|
|
fullscreen: false,
|
|
maximized: false,
|
|
floating: false,
|
|
})
|
|
})
|
|
.collect();
|
|
drop(shell.update_workspace_ui());
|
|
for scale in [1., 1.25, 1.5] {
|
|
shell.outputs[0].scale = scale;
|
|
let mut date_pixels = None;
|
|
for (name, mode, menu) in [
|
|
(
|
|
"activities-battery-full",
|
|
Mode::Expanded(Content::Activities),
|
|
Some(status_ui::Menu::Activities),
|
|
),
|
|
(
|
|
"activities-battery-low",
|
|
Mode::Expanded(Content::Activities),
|
|
Some(status_ui::Menu::Activities),
|
|
),
|
|
(
|
|
"activities-battery-light",
|
|
Mode::Expanded(Content::Activities),
|
|
Some(status_ui::Menu::Activities),
|
|
),
|
|
("activities-light", Mode::Idle, None),
|
|
("activities-narrow", Mode::Idle, None),
|
|
("activities-single", Mode::Idle, None),
|
|
("activities-split", Mode::Idle, None),
|
|
("activities-translucent", Mode::Idle, None),
|
|
(
|
|
"activities-list",
|
|
Mode::Expanded(Content::Activities),
|
|
Some(status_ui::Menu::Activities),
|
|
),
|
|
("idle", Mode::Idle, None),
|
|
("idle-attached", Mode::Idle, None),
|
|
("idle-attached-bottom", Mode::Idle, None),
|
|
("peek-attached", Mode::Peek, None),
|
|
("peek-attached-bottom", Mode::Peek, None),
|
|
(
|
|
"calendar-attached",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
(
|
|
"calendar-attached-bottom",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
("idle-translucent", Mode::Idle, None),
|
|
("idle-long-title", Mode::Idle, None),
|
|
("idle-recording", Mode::Idle, None),
|
|
("idle-low-battery", Mode::Idle, None),
|
|
("idle-recording-low-battery", Mode::Idle, None),
|
|
("idle-saving-low-battery", Mode::Idle, None),
|
|
("peek-recording", Mode::Peek, None),
|
|
("peek-low-battery", Mode::Peek, None),
|
|
("peek-recording-low-battery", Mode::Peek, None),
|
|
("peek-saving-low-battery", Mode::Peek, None),
|
|
("peek-date", Mode::Peek, None),
|
|
("peek-date-hover", Mode::Peek, None),
|
|
("peek-date-pressed", Mode::Peek, None),
|
|
("peek-media", Mode::Peek, None),
|
|
("peek-workspaces", Mode::Peek, None),
|
|
(
|
|
"workspaces",
|
|
Mode::Expanded(Content::Workspaces),
|
|
Some(status_ui::Menu::Workspaces),
|
|
),
|
|
(
|
|
"music",
|
|
Mode::Expanded(Content::Media),
|
|
Some(status_ui::Menu::Media),
|
|
),
|
|
(
|
|
"wifi-connected",
|
|
Mode::Expanded(Content::Controls),
|
|
Some(status_ui::Menu::Network),
|
|
),
|
|
(
|
|
"wifi-off",
|
|
Mode::Expanded(Content::Controls),
|
|
Some(status_ui::Menu::Network),
|
|
),
|
|
(
|
|
"battery",
|
|
Mode::Expanded(Content::Controls),
|
|
Some(status_ui::Menu::Battery),
|
|
),
|
|
(
|
|
"calendar",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
(
|
|
"alerts",
|
|
Mode::Expanded(Content::Notifications),
|
|
Some(status_ui::Menu::Notifications),
|
|
),
|
|
(
|
|
"controls",
|
|
Mode::Expanded(Content::Controls),
|
|
Some(status_ui::Menu::Overflow),
|
|
),
|
|
(
|
|
"controls-narrow",
|
|
Mode::Expanded(Content::Controls),
|
|
Some(status_ui::Menu::Overflow),
|
|
),
|
|
(
|
|
"controls-wide",
|
|
Mode::Expanded(Content::Controls),
|
|
Some(status_ui::Menu::Overflow),
|
|
),
|
|
(
|
|
"system",
|
|
Mode::Expanded(Content::Controls),
|
|
Some(status_ui::Menu::System),
|
|
),
|
|
(
|
|
"spring-60",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
(
|
|
"spring-120",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
(
|
|
"spring-190",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
(
|
|
"spring-250",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
(
|
|
"spring-400",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
(
|
|
"spring-900",
|
|
Mode::Expanded(Content::Calendar),
|
|
Some(status_ui::Menu::Calendar),
|
|
),
|
|
] {
|
|
std::sync::Arc::make_mut(&mut shell.media.snapshot)
|
|
.selected
|
|
.as_mut()
|
|
.unwrap()
|
|
.title = if name == "idle-long-title" {
|
|
"A very long track title that must truncate before the equalizer and never hide its bars".into()
|
|
} else {
|
|
"Losing My Mind".into()
|
|
};
|
|
shell.recorder.state = if name.contains("recording") {
|
|
crate::recording::State::Recording(
|
|
std::time::Instant::now() - Duration::from_secs(65),
|
|
)
|
|
} else if name.contains("saving") {
|
|
crate::recording::State::Saving
|
|
} else {
|
|
crate::recording::State::Idle
|
|
};
|
|
shell.status.battery =
|
|
name.contains("low-battery")
|
|
.then(|| crate::status::Battery {
|
|
percent: 12,
|
|
status: "Discharging".into(),
|
|
});
|
|
if name.starts_with("activities-battery-") {
|
|
shell.status.battery = Some(crate::status::Battery {
|
|
percent: if name.ends_with("full") {
|
|
100
|
|
} else if name.ends_with("low") {
|
|
9
|
|
} else {
|
|
87
|
|
},
|
|
status: if name.ends_with("full") {
|
|
"Full"
|
|
} else if name.ends_with("low") {
|
|
"Discharging"
|
|
} else {
|
|
"Charging"
|
|
}
|
|
.into(),
|
|
});
|
|
}
|
|
let document = ferese_config::Document::parse(
|
|
if matches!(name, "activities-light" | "activities-battery-light") {
|
|
"theme { mode light; }"
|
|
} else {
|
|
"theme { mode dark; }"
|
|
},
|
|
)
|
|
.unwrap();
|
|
let candidate = ferese_config::theme::resolve(
|
|
&document,
|
|
std::path::Path::new("/tmp"),
|
|
jiff::Timestamp::now(),
|
|
|_| Err("unexpected theme read".into()),
|
|
)
|
|
.unwrap();
|
|
shell.config.apply_theme(&candidate.theme);
|
|
shell.config.theme.bar_background[3] = if name.ends_with("translucent") {
|
|
128
|
|
} else {
|
|
255
|
|
};
|
|
let panel = &mut shell.config.panels[0];
|
|
panel.morph.attached = name.contains("attached");
|
|
panel.edge = if name.ends_with("bottom") {
|
|
Edge::Bottom
|
|
} else {
|
|
Edge::Top
|
|
};
|
|
panel.morph.expanded_width = match name {
|
|
"controls-narrow" => 240.,
|
|
"controls-wide" => 400.,
|
|
_ => 340.,
|
|
};
|
|
if name.starts_with("wifi-") {
|
|
shell.status.network = Some(crate::status::Network {
|
|
enabled: name == "wifi-connected",
|
|
connection: (name == "wifi-connected").then(|| "Home network".into()),
|
|
signal: 80,
|
|
});
|
|
}
|
|
shell.outputs[0].size = Some((
|
|
if name == "activities-narrow" {
|
|
240
|
|
} else {
|
|
1200
|
|
},
|
|
800,
|
|
));
|
|
let workspace_width = shell.hub_workspace_width(id);
|
|
let hub = shell.outputs[0].runtime.hub.as_ref().unwrap();
|
|
let (mut sample, mut allocation) = {
|
|
let mut hub = hub.borrow_mut();
|
|
let animation_time = name
|
|
.strip_prefix("spring-")
|
|
.map(|ms| ms.parse::<u64>().unwrap());
|
|
*hub = Runtime::new(&shell.config.panels[0]);
|
|
hub.workspace_width = if workspace_width > 0. {
|
|
workspace_width + 10.
|
|
} else {
|
|
0.
|
|
};
|
|
hub.peek = match name {
|
|
_ if shell.hub_priority().is_some() => shell.hub_priority().unwrap(),
|
|
"peek-media" => Peek::Media,
|
|
"peek-workspaces" => Peek::Workspaces,
|
|
_ => Peek::Date,
|
|
};
|
|
hub.peek_width = if shell.hub_priority().is_some() {
|
|
shell.hub_peek_content(id).1
|
|
} else if name == "peek-media" {
|
|
260.
|
|
} else {
|
|
200.
|
|
};
|
|
hub.retarget(
|
|
mode,
|
|
[
|
|
if name == "activities-narrow" {
|
|
240.
|
|
} else {
|
|
1200.
|
|
},
|
|
800.,
|
|
],
|
|
menu,
|
|
);
|
|
hub.hub.advance(
|
|
Duration::from_millis(animation_time.unwrap_or(0)),
|
|
animation_time.is_none(),
|
|
);
|
|
(hub.hub.sample(), hub.allocation)
|
|
};
|
|
if name.starts_with("activities") {
|
|
seed_activities(&mut shell, if name == "activities-single" { 1 } else { 3 });
|
|
drop(shell.sync_hub_activity());
|
|
let mut hub = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut();
|
|
hub.hub.advance(Duration::from_secs(2), true);
|
|
hub.activities.advance(Duration::ZERO, true);
|
|
sample = hub.hub.sample();
|
|
allocation = hub.allocation;
|
|
} else {
|
|
shell.activities.snapshot = std::sync::Arc::new(Default::default());
|
|
}
|
|
let hub = shell.outputs[0].runtime.hub.as_ref().unwrap();
|
|
if mode == Mode::Idle
|
|
|| mode == Mode::Expanded(Content::Controls)
|
|
|| name == "peek-workspaces"
|
|
{
|
|
let mut measured = shell.view_hub(id);
|
|
let mut measured_tree = widget::Tree::new(&measured);
|
|
measured.as_widget_mut().layout(
|
|
&mut measured_tree,
|
|
&renderer,
|
|
&layout::Limits::new(
|
|
Size::ZERO,
|
|
Size::new(allocation[0] as f32, allocation[1] as f32),
|
|
),
|
|
);
|
|
let idle_width_limit = f64::from(shell.hub_idle_width_limit(id));
|
|
let mut runtime = hub.borrow_mut();
|
|
runtime.hub.advance(Duration::ZERO, true);
|
|
sample = runtime.hub.sample();
|
|
if name == "peek-workspaces" {
|
|
assert!(
|
|
sample.geometry.bounds.width < 200.,
|
|
"short workspace feedback should fit its dots and name"
|
|
);
|
|
}
|
|
if mode == Mode::Idle {
|
|
assert!(
|
|
sample.geometry.bounds.width <= idle_width_limit,
|
|
"idle should fit its content instead of retaining a fixed text allowance"
|
|
);
|
|
}
|
|
if name == "controls" || name.starts_with("wifi-") {
|
|
assert!(
|
|
sample.geometry.bounds.height
|
|
< shell.config.panels[0].morph.controls_height,
|
|
"{name} should shrink to its content"
|
|
);
|
|
}
|
|
}
|
|
let mut view = shell.view_hub_at(id, sample, menu, None);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let viewport =
|
|
Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32));
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, viewport.size()),
|
|
);
|
|
if shell.hub_priority().is_some() && !mode.expanded() {
|
|
#[derive(Default)]
|
|
struct TextBounds(Vec<(String, Rectangle)>);
|
|
impl widget::Operation for TextBounds {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
|
visit(self);
|
|
}
|
|
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
|
self.0.push((text.to_owned(), bounds));
|
|
}
|
|
}
|
|
let mut bounds = TextBounds::default();
|
|
view.as_widget_mut().operate(
|
|
&mut tree,
|
|
Layout::new(&node),
|
|
&renderer,
|
|
&mut bounds,
|
|
);
|
|
let left = (viewport.width - sample.geometry.bounds.width as f32) / 2.;
|
|
let right = left + sample.geometry.bounds.width as f32;
|
|
for (label, rect) in &bounds.0 {
|
|
assert!(
|
|
rect.x >= left && rect.x + rect.width <= right + 0.1,
|
|
"{name}: {label} must fit inside shell: {rect:?}, {left}..{right}"
|
|
);
|
|
assert!(
|
|
rect.height <= 28.,
|
|
"{name}: {label} must stay on one line: {rect:?}"
|
|
);
|
|
}
|
|
assert!(
|
|
!bounds.0.iter().any(|(text, _)| text == "Losing My Mind"),
|
|
"media cannot displace priority status"
|
|
);
|
|
// Exercise the nested battery button as well as its recording parent.
|
|
if name == "peek-recording-low-battery" {
|
|
for (label, expected) in [
|
|
("12", status_ui::Menu::Battery),
|
|
("Recording", status_ui::Menu::Overflow),
|
|
] {
|
|
let rect = bounds.0.iter().find(|(text, _)| text == label).unwrap().1;
|
|
let cursor = mouse::Cursor::Available(rect.center());
|
|
let mut messages = Vec::new();
|
|
for event in [
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
] {
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&Event::Mouse(event),
|
|
Layout::new(&node),
|
|
cursor,
|
|
&renderer,
|
|
&mut clipboard::Null,
|
|
&mut Shell::new(&mut messages),
|
|
&viewport,
|
|
);
|
|
}
|
|
let opened: Vec<_> = messages
|
|
.iter()
|
|
.filter_map(|message| match message {
|
|
cosmic::Action::App(Message::HubOpen(_, menu)) => Some(*menu),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
opened,
|
|
vec![expected],
|
|
"{label} must open exactly its own controls"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if name == "peek-date" || name == "peek-workspaces" {
|
|
let mut messages = Vec::new();
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&Event::Mouse(mouse::Event::WheelScrolled {
|
|
delta: mouse::ScrollDelta::Lines { x: 0., y: -1. },
|
|
}),
|
|
Layout::new(&node),
|
|
mouse::Cursor::Available(cosmic::iced::Point::new(
|
|
viewport.width / 2.,
|
|
22.,
|
|
)),
|
|
&renderer,
|
|
&mut clipboard::Null,
|
|
&mut Shell::new(&mut messages),
|
|
&viewport,
|
|
);
|
|
assert_eq!(
|
|
messages
|
|
.iter()
|
|
.filter(|message| matches!(
|
|
message,
|
|
cosmic::Action::App(Message::HubWorkspaceScroll(_, _))
|
|
))
|
|
.count(),
|
|
1,
|
|
"peek must handle a wheel event exactly once"
|
|
);
|
|
}
|
|
let cursor = if name == "peek-date-hover" || name == "peek-date-pressed" {
|
|
let position =
|
|
cosmic::iced::Point::new(viewport.width / 2., viewport.height / 2.);
|
|
let cursor = mouse::Cursor::Available(position);
|
|
let mut messages = Vec::new();
|
|
let event = if name == "peek-date-pressed" {
|
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
|
} else {
|
|
Event::Mouse(mouse::Event::CursorMoved { position })
|
|
};
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&event,
|
|
Layout::new(&node),
|
|
cursor,
|
|
&renderer,
|
|
&mut clipboard::Null,
|
|
&mut Shell::new(&mut messages),
|
|
&viewport,
|
|
);
|
|
cursor
|
|
} else {
|
|
mouse::Cursor::Unavailable
|
|
};
|
|
let theme = shell.config.theme.palette().native_theme();
|
|
renderer.reset(viewport);
|
|
view.as_widget().draw(
|
|
&tree,
|
|
&mut renderer,
|
|
&theme,
|
|
&cosmic::iced::advanced::renderer::Style {
|
|
text_color: crate::color(shell.config.theme.text_primary),
|
|
icon_color: crate::color(shell.config.theme.text_primary),
|
|
scale_factor: scale,
|
|
},
|
|
Layout::new(&node),
|
|
cursor,
|
|
&viewport,
|
|
);
|
|
let physical = Size::new(
|
|
(allocation[0] as f64 * scale).ceil() as u32,
|
|
(allocation[1] as f64 * scale).ceil() as u32,
|
|
);
|
|
let pixels =
|
|
renderer.screenshot(physical, scale as f32, cosmic::iced::Color::TRANSPARENT);
|
|
assert!(pixels.chunks_exact(4).any(|pixel| pixel[3] > 0));
|
|
if name == "idle-translucent" {
|
|
let x = (((viewport.width + sample.geometry.bounds.width as f32) / 2. - 8.)
|
|
* scale as f32) as usize;
|
|
let y = (sample.geometry.bounds.height * scale / 2.) as usize;
|
|
let alpha = pixels[(y * physical.width as usize + x) * 4 + 3];
|
|
assert!(
|
|
alpha.abs_diff(128) <= 2,
|
|
"Morph must inherit theme alpha, got {alpha}"
|
|
);
|
|
}
|
|
if name == "idle-long-title" {
|
|
let right = (viewport.width + sample.geometry.bounds.width as f32) / 2.;
|
|
let left_x = ((right - 28.) * scale as f32).floor() as usize;
|
|
let right_x = ((right - 14.) * scale as f32).ceil() as usize;
|
|
let accent = shell.config.theme.accent;
|
|
assert!(
|
|
pixels.chunks_exact(4).enumerate().any(|(index, pixel)| {
|
|
let x = index % physical.width as usize;
|
|
(left_x..right_x).contains(&x)
|
|
&& pixel[3] > 200
|
|
&& pixel[..3]
|
|
.iter()
|
|
.zip(accent[..3].iter())
|
|
.all(|(a, b)| a.abs_diff(*b) <= 2)
|
|
}),
|
|
"long titles must leave the equalizer visible at the right edge"
|
|
);
|
|
}
|
|
|
|
if name == "peek-date" {
|
|
date_pixels = Some(pixels.clone());
|
|
} else if name == "peek-date-hover" || name == "peek-date-pressed" {
|
|
assert_eq!(
|
|
Some(&pixels),
|
|
date_pixels.as_ref(),
|
|
"Peek must not paint a hover or pressed overlay"
|
|
);
|
|
}
|
|
image_codec::save_buffer(
|
|
format!("/tmp/ferese-hub-review/{backend}-{name}-{scale}.png"),
|
|
&pixels,
|
|
physical.width,
|
|
physical.height,
|
|
image_codec::ColorType::Rgba8,
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn content_layout_stays_fixed_through_spring_settlement_and_rebuilds() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
#[derive(Default)]
|
|
struct Bounds {
|
|
content: Option<Rectangle>,
|
|
text: Vec<(String, Rectangle)>,
|
|
}
|
|
impl widget::Operation for Bounds {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
|
visit(self);
|
|
}
|
|
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
|
if id == Some(&widget::Id::new("ferese-hub-content")) {
|
|
self.content = Some(bounds);
|
|
}
|
|
}
|
|
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
|
self.text.push((text.to_owned(), bounds));
|
|
}
|
|
}
|
|
fn close(a: Rectangle, b: Rectangle) {
|
|
for (a, b) in [
|
|
(a.x, b.x),
|
|
(a.y, b.y),
|
|
(a.width, b.width),
|
|
(a.height, b.height),
|
|
] {
|
|
assert!(
|
|
(a - b).abs() < 0.001,
|
|
"content moved during settlement: {a} vs {b}"
|
|
);
|
|
}
|
|
}
|
|
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 = shell();
|
|
let id = shell.outputs[0].bar;
|
|
for edge in [Edge::Top, Edge::Bottom] {
|
|
shell.config.panels[0].edge = edge;
|
|
for scale in [1., 1.25, 1.5] {
|
|
shell.outputs[0].scale = scale;
|
|
for mode in [Mode::Peek, Mode::Expanded(Content::Calendar), Mode::Idle] {
|
|
let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap();
|
|
runtime.borrow_mut().retarget(mode, [1200., 800.], None);
|
|
let allocation = runtime.borrow().allocation;
|
|
let mut baseline: Option<Bounds> = None;
|
|
let mut elapsed = 0;
|
|
for at in [180, 250, 400, 650, 1000, 1600, 1600] {
|
|
let sample = {
|
|
let mut runtime = runtime.borrow_mut();
|
|
runtime
|
|
.hub
|
|
.advance(Duration::from_millis(at - elapsed), false);
|
|
elapsed = at;
|
|
runtime.hub.sample()
|
|
};
|
|
let mut view = shell.view_hub_at(id, sample, None, None);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(
|
|
Size::ZERO,
|
|
Size::new(allocation[0] as f32, allocation[1] as f32),
|
|
),
|
|
);
|
|
let mut bounds = Bounds::default();
|
|
view.as_widget_mut().operate(
|
|
&mut tree,
|
|
Layout::new(&node),
|
|
&renderer,
|
|
&mut bounds,
|
|
);
|
|
assert!(bounds.content.is_some());
|
|
assert!(!bounds.text.is_empty());
|
|
if let Some(baseline) = &baseline {
|
|
close(bounds.content.unwrap(), baseline.content.unwrap());
|
|
assert_eq!(bounds.text.len(), baseline.text.len());
|
|
for ((text, rect), (expected_text, expected_rect)) in
|
|
bounds.text.iter().zip(&baseline.text)
|
|
{
|
|
assert_eq!(text, expected_text);
|
|
close(*rect, *expected_rect);
|
|
}
|
|
} else {
|
|
baseline = Some(bounds);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hub_dimensions_snap_before_centering_on_odd_and_even_backing_surfaces() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
#[derive(Default)]
|
|
struct Bounds(Option<Rectangle>);
|
|
impl widget::Operation for Bounds {
|
|
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
|
children(self);
|
|
}
|
|
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
|
if id == Some(&widget::Id::new("ferese-panel-hub")) {
|
|
self.0 = Some(bounds);
|
|
}
|
|
}
|
|
}
|
|
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 = shell();
|
|
let id = shell.outputs[0].bar;
|
|
let sample = shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow()
|
|
.hub
|
|
.sample();
|
|
for scale in [1., 1.25, 1.5, 1.75] {
|
|
shell.outputs[0].scale = scale;
|
|
for backing_width in [329., 330., 401.] {
|
|
for delta in [-0.02, 0., 0.02] {
|
|
let mut sample = sample;
|
|
sample.geometry.bounds.width = 320. + delta;
|
|
sample.geometry.bounds.height = 48. + delta;
|
|
let mut view = shell.view_hub_at(id, sample, None, None);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, Size::new(backing_width, 230.)),
|
|
);
|
|
let mut bounds = Bounds::default();
|
|
view.as_widget_mut().operate(
|
|
&mut tree,
|
|
Layout::new(&node),
|
|
&renderer,
|
|
&mut bounds,
|
|
);
|
|
let bounds = bounds.0.unwrap();
|
|
assert_eq!(
|
|
bounds.width, 320.,
|
|
"scale={scale}, backing={backing_width}, delta={delta}"
|
|
);
|
|
assert_eq!(
|
|
bounds.height, 48.,
|
|
"scale={scale}, backing={backing_width}, delta={delta}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn native_input_follows_the_animated_hub_and_bottom_anchor_mirrors_it() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
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 = shell();
|
|
let id = shell.outputs[0].bar;
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
// Render the initial sample while the backing surface is already expanded.
|
|
let hub = shell.outputs[0].runtime.hub.as_ref().unwrap();
|
|
let sample = hub.borrow().hub.sample();
|
|
for edge in [Edge::Top, Edge::Bottom] {
|
|
shell.config.panels[0].edge = edge;
|
|
let mut view = shell.view_hub_at(id, sample, Some(status_ui::Menu::Calendar), None);
|
|
let mut tree = widget::Tree::new(&view);
|
|
let viewport = Rectangle::with_size(Size::new(340., 230.));
|
|
let mut node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, viewport.size()),
|
|
);
|
|
let mut messages = Vec::new();
|
|
let event = Event::Window(window::Event::RedrawRequested(Instant::now()));
|
|
for _ in 0..2 {
|
|
let mut update = Shell::new(&mut messages);
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&event,
|
|
Layout::new(&node),
|
|
mouse::Cursor::Unavailable,
|
|
&renderer,
|
|
&mut clipboard::Null,
|
|
&mut update,
|
|
&viewport,
|
|
);
|
|
if update.is_layout_invalid() {
|
|
node = view.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, viewport.size()),
|
|
);
|
|
}
|
|
}
|
|
let scene = messages
|
|
.iter()
|
|
.find_map(|message| match message {
|
|
cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene),
|
|
_ => None,
|
|
})
|
|
.expect("native material and input scene");
|
|
assert!(scene.contains([170., if edge == Edge::Top { 17. } else { 213. }]));
|
|
assert!(
|
|
!scene.contains([20., 17.]),
|
|
"unused backing space is click-through"
|
|
);
|
|
assert!(
|
|
!scene.contains([170., 100.]),
|
|
"target geometry must not steal input before it is visible"
|
|
);
|
|
assert_eq!(scene.damage.len(), 1);
|
|
assert!(scene.damage[0].width <= 151.);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_animation_frame_publishes_its_measured_shape_without_event_replay() {
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
use cosmic::iced::runtime::UserInterface;
|
|
let mut 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 = shell();
|
|
let id = shell.outputs[0].bar;
|
|
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
|
let start = Instant::now() + Duration::from_secs(1);
|
|
shell.outputs[0]
|
|
.runtime
|
|
.hub
|
|
.as_ref()
|
|
.unwrap()
|
|
.borrow_mut()
|
|
.sampled = start;
|
|
let mut ui = UserInterface::build(
|
|
shell.view_layer(id),
|
|
Size::new(340., 248.),
|
|
Default::default(),
|
|
&mut renderer,
|
|
);
|
|
let mut last_width = 0.;
|
|
// Sample before the spring tail rounds to the same physical pixel.
|
|
for frame in 1..=8 {
|
|
let mut messages = Vec::new();
|
|
ui.update(
|
|
&[Event::Window(window::Event::RedrawRequested(
|
|
start + Duration::from_millis(frame * 8),
|
|
))],
|
|
mouse::Cursor::Unavailable,
|
|
&mut renderer,
|
|
&mut clipboard::Null,
|
|
&mut messages,
|
|
);
|
|
let scene = messages
|
|
.iter()
|
|
.find_map(|message| match message {
|
|
cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_else(|| {
|
|
panic!("frame {frame} must publish its freshly laid-out native shape")
|
|
});
|
|
let width = scene.paint[0].bounds.width;
|
|
assert!(
|
|
width > last_width,
|
|
"frame {frame}: {width} must exceed {last_width}"
|
|
);
|
|
last_width = width;
|
|
}
|
|
}
|
|
}
|