Files
feresaria/shell/ferese-shell/src/panel_hub/workspaces.rs
T

496 lines
17 KiB
Rust

use std::time::{Duration, Instant};
use cosmic::app::Task;
use cosmic::iced::mouse::ScrollDelta;
use cosmic::iced::{Alignment, window};
use cosmic::widget::{button, container, row};
use cosmic::{Element, theme};
use ferese_panel::ItemKind;
use ferese_panel::morph::Mode;
use super::Event;
use crate::control::WorkspaceSnapshot;
use crate::{FereseShell, Message, color_with_opacity, motion};
const SLOT: f32 = 12.;
const ACTIVE_EXTRA: f32 = 12.;
const DOT_INSET: f32 = 3.;
const LIMIT: usize = 4;
struct Selection<'a> {
workspaces: Vec<&'a WorkspaceSnapshot>,
hidden: usize,
}
impl Selection<'_> {
fn width(&self) -> f32 {
if self.workspaces.is_empty() {
return 0.;
}
// Six-pixel gaps, room for the active pill, and an optional overflow count.
self.workspaces.len() as f32 * SLOT + ACTIVE_EXTRA - DOT_INSET
+ if self.hidden > 0 {
6. + (self.hidden.to_string().len() + 1) as f32 * 7.
} else {
0.
}
}
}
fn select(mut workspaces: Vec<&WorkspaceSnapshot>, active: u64, budget: f32) -> Selection<'_> {
workspaces.retain(|workspace| workspace.window_count > 0 || workspace.id == active);
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
let count = workspaces.len();
if count == 0 || (count <= LIMIT && count as f32 * SLOT + ACTIVE_EXTRA - DOT_INSET <= budget) {
return Selection {
workspaces,
hidden: 0,
};
}
let overflow_width = 6. + (count.to_string().len() + 1) as f32 * 7.;
let visible = (((budget - overflow_width - ACTIVE_EXTRA + DOT_INSET) / SLOT)
.floor()
.max(1.) as usize)
.min(LIMIT)
.min(count);
let position = workspaces
.iter()
.position(|workspace| workspace.id == active)
.unwrap_or(0);
let start = position.saturating_sub(visible / 2).min(count - visible);
Selection {
workspaces: workspaces[start..start + visible].to_vec(),
hidden: count - visible,
}
}
#[derive(Default)]
pub(super) struct Scroll {
amount: f32,
pixels: bool,
last_input: Option<Instant>,
last_switch: Option<Instant>,
}
impl Scroll {
fn step(&mut self, delta: ScrollDelta, now: Instant) -> Option<i32> {
let (x, y, pixels) = match delta {
ScrollDelta::Lines { x, y } => (x, y, false),
ScrollDelta::Pixels { x, y } => (x, y, true),
};
let amount = if y.abs() >= x.abs() { y } else { -x };
if !amount.is_finite() || amount == 0. {
return None;
}
if pixels != self.pixels
|| self
.last_input
.is_none_or(|last| now.saturating_duration_since(last) > Duration::from_millis(250))
|| self.amount.signum() != amount.signum()
{
self.amount = 0.;
}
self.pixels = pixels;
self.last_input = Some(now);
if self
.last_switch
.is_some_and(|last| now.saturating_duration_since(last) < Duration::from_millis(120))
{
return None;
}
self.amount += amount;
let threshold = if pixels { 40. } else { 1. };
if self.amount.abs() < threshold {
return None;
}
let direction = if self.amount > 0. { -1 } else { 1 };
self.amount = 0.;
self.last_switch = Some(now);
Some(direction)
}
}
impl FereseShell {
fn hub_workspace_selection(&self, id: window::Id) -> Selection<'_> {
let panel = self.panel_for_bar(id);
let enabled = [&panel.start, &panel.center, &panel.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.flat_map(|group| &group.items)
.any(|item| item.visible && matches!(item.kind, ItemKind::Workspaces { .. }));
let Some(output) = self.output_for_bar(id).filter(|_| enabled) else {
return Selection {
workspaces: Vec::new(),
hidden: 0,
};
};
let available = super::content_width(
panel,
self.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.size),
);
select(
self.snapshot
.workspaces_for_output(Some(output.id))
.collect(),
output.active_workspace,
// Keep the compact strip within its existing width budget.
(panel.morph.expanded_width.min(available) - 28. - 5. * 32. - 4.).max(0.) as f32,
)
}
pub(crate) fn hub_workspace_width(&self, id: window::Id) -> f64 {
f64::from(self.hub_workspace_selection(id).width())
}
pub(crate) fn sync_hub_workspaces(&mut self) -> Task<Message> {
let changes: Vec<_> = self
.outputs
.iter()
.filter_map(|entry| {
let runtime = entry.runtime.hub.as_ref()?;
let width = self.hub_workspace_width(entry.bar);
let width = if width > 0. { width + 10. } else { 0. };
let mut runtime = runtime.borrow_mut();
if runtime.observed_workspace.is_none() {
runtime.observed_workspace = self
.output_for_bar(entry.bar)
.map(|output| output.active_workspace);
}
if runtime.workspace_width == width {
return None;
}
runtime.workspace_width = width;
matches!(runtime.hub.mode(), Mode::Idle).then_some(entry.bar)
})
.collect();
Task::batch(
changes
.into_iter()
.map(|id| self.transition_hub(id, Event::Resize)),
)
}
pub(crate) fn open_hub_overview(&mut self, id: window::Id) -> Task<Message> {
self.open_hub(id, crate::status_ui::Menu::Workspaces)
}
pub(crate) fn sync_hub_workspace_feedback(&mut self, suppressed: bool) -> Task<Message> {
let changed: Vec<_> = self
.outputs
.iter()
.filter_map(|entry| {
let active = self.output_for_bar(entry.bar)?.active_workspace;
let mut runtime = entry.runtime.hub.as_ref()?.borrow_mut();
let previous = runtime.observed_workspace.replace(active);
let selected = runtime.workspace_selection_pending == Some(active);
if selected {
runtime.workspace_selection_pending = None;
}
(!suppressed
&& !selected
&& previous.is_some_and(|previous| previous != active)
&& matches!(runtime.hub.mode(), Mode::Idle | Mode::Peek))
.then_some(entry.bar)
})
.collect();
Task::batch(
changed
.into_iter()
.map(|id| self.show_hub_workspace_feedback(id)),
)
}
fn show_hub_workspace_feedback(&mut self, id: window::Id) -> Task<Message> {
if self.bar_hidden(id)
|| self.hub_workspace_width(id) == 0.
|| self.hub_priority().is_some()
{
return Task::none();
}
let Some(runtime) = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.runtime.hub.as_ref())
else {
return Task::none();
};
let revision = Instant::now();
let first = runtime.borrow().transient.workspace_token().is_none();
let policy = self.render.motion;
let fade = if first && policy.motion_enabled() {
0.25 / policy.speed
} else {
0.
};
let timeout = Duration::from_secs_f64(1.2 + fade);
Task::batch([
self.transition_hub(id, Event::Workspaces(revision)),
Task::perform(
async move {
tokio::time::sleep(timeout).await;
},
move |_| cosmic::Action::App(Message::HubWorkspaceFeedbackExpired(id, revision)),
),
])
}
pub(crate) fn hub_workspace_feedback_expired(
&mut self,
id: window::Id,
revision: Instant,
) -> Task<Message> {
self.transition_hub(id, Event::WorkspacesExpired(revision))
}
pub(crate) fn scroll_hub_workspaces(
&mut self,
id: window::Id,
delta: ScrollDelta,
) -> Task<Message> {
if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. {
return Task::none();
}
let Some(runtime) = self
.outputs
.iter()
.find(|entry| entry.bar == id)
.and_then(|entry| entry.runtime.hub.as_ref())
else {
return Task::none();
};
let Some(direction) = runtime
.borrow_mut()
.workspace_scroll
.step(delta, Instant::now())
else {
return Task::none();
};
let Some(output) = self.output_for_bar(id) else {
return Task::none();
};
// Include the trailing empty workspace so scrolling can create a new working space.
let mut workspaces: Vec<_> = self
.snapshot
.workspaces_for_output(Some(output.id))
.collect();
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
if let Some(position) = workspaces
.iter()
.position(|workspace| workspace.id == output.active_workspace)
&& let Some(next) = position
.checked_add_signed(direction as isize)
.and_then(|next| workspaces.get(next))
&& let Some(control) = &self.control
{
control.activate_workspace(next.id);
}
self.dismiss_workspace_tooltip_for_bar(id)
}
pub(crate) fn hub_workspaces(
&self,
id: window::Id,
opacity: f32,
) -> Element<'_, cosmic::Action<Message>> {
let selection = self.hub_workspace_selection(id);
let width = selection.width();
let output = self.output_for_bar(id);
let active = output.map_or(0, |output| output.active_workspace);
let palette = self.config.theme.palette();
let foreground = color_with_opacity(self.config.theme.text_primary, opacity);
let hidden_urgent = self.snapshot.windows.iter().any(|window| {
window.urgent
&& !selection
.workspaces
.iter()
.any(|workspace| workspace.id == window.workspace)
&& self
.snapshot
.workspaces_for_output(output.map(|output| output.id))
.any(|workspace| workspace.id == window.workspace)
});
let mut dots = row::with_capacity(LIMIT).align_y(Alignment::Center);
let mut label = selection
.workspaces
.iter()
.find(|workspace| workspace.id == active)
.map_or_else(
|| "Workspace overview".to_owned(),
|workspace| {
format!(
"Workspace {} · {} · {} windows · Active · Open overview",
workspace.index, workspace.name, workspace.window_count
)
},
);
for (index, workspace) in selection.workspaces.iter().enumerate() {
let urgent = self
.snapshot
.windows
.iter()
.any(|window| window.workspace == workspace.id && window.urgent);
if urgent {
label.push_str(&format!(" · Workspace {} needs attention", workspace.index));
}
let transition = self
.workspace_ui
.transition(workspace.id, workspace.id == active);
dots = dots.push(motion::geometry_driven(
transition.revision(),
move |now| {
let progress = transition.progress(now).clamp(0., 1.);
let color = if urgent {
palette.error
} else {
ferese_theme::mix(palette.muted, palette.accent, progress)
};
container(
container(cosmic::widget::Space::new())
.width(6. + 12. * progress)
.height(6.)
.class(theme::Container::custom(move |_| container::Style {
background: Some(color.scale_alpha(opacity).into()),
border: cosmic::iced::Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
})),
)
// The first dot starts at the group's edge; only the gaps
// between dots need the two half-slot insets.
.padding(cosmic::iced::Padding {
left: if index == 0 { 0. } else { DOT_INSET },
right: DOT_INSET,
..Default::default()
})
.center_y(28)
.into()
},
move |now| transition.active(now),
|_| {},
|_| None,
None,
));
}
if selection.hidden > 0 {
label.push_str(&format!(" · {} other workspaces", selection.hidden));
if hidden_urgent {
label.push_str(" · Another workspace needs attention");
}
dots = dots.push(
container(
self.render
.text(format!("+{}", selection.hidden))
.size(11)
.class(theme::Text::Color(if hidden_urgent {
palette.error.scale_alpha(opacity)
} else {
foreground
})),
)
.padding([0, 3]),
);
}
let appearance = button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
..Default::default()
};
let control = button::custom(dots)
.name(label)
.padding(0)
.width(width)
.height(28)
.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::HubOverview(id)));
super::scroll::area(control.into(), id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overflow_keeps_the_active_empty_workspace_and_order() {
let workspaces: Vec<_> = (1..=20)
.map(|id| WorkspaceSnapshot {
id,
index: id as u32,
name: id.to_string(),
output: Some(1),
window_count: u32::from(id != 20),
visible: id == 20,
focused: id == 20,
})
.collect();
for active in [1, 8, 20] {
let selection = select(workspaces.iter().collect(), active, 200.);
assert!(
selection
.workspaces
.iter()
.any(|workspace| workspace.id == active)
);
assert!(
selection
.workspaces
.windows(2)
.all(|pair| pair[0].index < pair[1].index)
);
assert_eq!(selection.workspaces.len(), 4);
assert_eq!(selection.hidden, if active == 20 { 16 } else { 15 });
let narrow = select(workspaces.iter().collect(), active, 148.);
assert!(narrow.width() <= 148.);
assert!(
narrow
.workspaces
.iter()
.any(|workspace| workspace.id == active)
);
}
}
#[test]
fn trackpad_accumulates_and_resets_between_gestures() {
let mut scroll = Scroll::default();
let start = Instant::now();
let pixels = |y| ScrollDelta::Pixels { x: 0., y };
assert_eq!(scroll.step(pixels(-20.), start), None);
assert_eq!(
scroll.step(pixels(-20.), start + Duration::from_millis(10)),
Some(1)
);
assert_eq!(
scroll.step(pixels(-100.), start + Duration::from_millis(20)),
None
);
assert_eq!(
scroll.step(pixels(20.), start + Duration::from_millis(200)),
None
);
assert_eq!(
scroll.step(pixels(20.), start + Duration::from_millis(500)),
None
);
assert_eq!(
scroll.step(pixels(20.), start + Duration::from_millis(510)),
Some(-1)
);
assert_eq!(
scroll.step(pixels(f32::NAN), start + Duration::from_secs(1)),
None
);
}
}