2891 lines
106 KiB
Rust
2891 lines
106 KiB
Rust
mod config;
|
|
mod control;
|
|
mod motion;
|
|
mod note_store;
|
|
mod notification_ui;
|
|
mod notifications;
|
|
mod status;
|
|
mod status_ui;
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
// Older libcosmic backends expose native surfaces in frame events. Keep
|
|
// that compatibility path gated; current backends use Window::Opened and
|
|
// window::run to retrieve handles without updates at the display refresh rate.
|
|
static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true);
|
|
|
|
use cosmic::Element;
|
|
use cosmic::app::{Core, Settings, Task};
|
|
use cosmic::iced::alignment;
|
|
use cosmic::iced::event::{self, PlatformSpecific, wayland};
|
|
use cosmic::iced::platform_specific::{
|
|
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings},
|
|
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
|
|
shell::commands::layer_surface::{
|
|
destroy_layer_surface, set_anchor, set_exclusive_zone, set_input_zone, set_margin, set_size,
|
|
},
|
|
};
|
|
use cosmic::iced::{
|
|
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window,
|
|
};
|
|
use cosmic::theme;
|
|
use cosmic::widget::{button, container, icon, image, row};
|
|
use ferese_protocols::effects::v1::client::{
|
|
ferese_effects_manager_v1::FereseEffectsManagerV1,
|
|
ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1},
|
|
};
|
|
use jiff::Zoned;
|
|
use wayland_client::{
|
|
Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop,
|
|
globals::{GlobalListContents, registry_queue_init},
|
|
protocol::{wl_output, wl_registry, wl_surface},
|
|
};
|
|
|
|
use crate::config::{ShellConfig, ShellTheme, WallpaperMode};
|
|
use crate::control::{ShellControl, ShellSnapshot};
|
|
|
|
const APP_ID: &str = "dev.ferese.Shell";
|
|
|
|
static SHELL_FONT: std::sync::OnceLock<std::sync::RwLock<cosmic::font::Font>> =
|
|
std::sync::OnceLock::new();
|
|
|
|
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
|
|
family.map_or_else(cosmic::font::default, |family| {
|
|
// Font names require static storage. Intern repeated families and cap
|
|
// the cache so live theme edits cannot leak unlimited strings.
|
|
static FONTS: std::sync::OnceLock<
|
|
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
|
|
> = std::sync::OnceLock::new();
|
|
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
|
|
if let Some(font) = fonts.get(family) {
|
|
return *font;
|
|
}
|
|
if fonts.len() >= 64 {
|
|
return cosmic::font::default();
|
|
}
|
|
let family = Box::leak(family.to_owned().into_boxed_str());
|
|
let font = cosmic::font::Font::with_name(family);
|
|
fonts.insert(family.to_owned(), font);
|
|
font
|
|
})
|
|
}
|
|
|
|
// COSMIC's text helper explicitly selects its interface font, overriding
|
|
// Settings::default_font. Use this helper for every bar and menu label.
|
|
fn text<'a>(
|
|
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
|
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
|
cosmic::widget::text(content).font(
|
|
*SHELL_FONT
|
|
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
|
|
.read()
|
|
.unwrap(),
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct BarMetrics {
|
|
height: f32,
|
|
text_size: u16,
|
|
icon_size: u16,
|
|
overview_icon_size: u16,
|
|
control_height: f32,
|
|
}
|
|
|
|
impl From<ShellTheme> for BarMetrics {
|
|
fn from(theme: ShellTheme) -> Self {
|
|
let height = theme.bar_height;
|
|
|
|
Self {
|
|
height,
|
|
// Logical sizes: Wayland/iced applies each surface's output scale.
|
|
// Compacting the bar must not also shrink its readable content.
|
|
text_size: 14,
|
|
icon_size: 20,
|
|
overview_icon_size: 20,
|
|
control_height: (height - 4.0).max(21.0).min(height),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
|
|
let mut parts = label.split_whitespace();
|
|
let time = parts.next()?;
|
|
if let Some(period) = parts.next() {
|
|
if !period.eq_ignore_ascii_case("am") && !period.eq_ignore_ascii_case("pm") {
|
|
return None;
|
|
}
|
|
}
|
|
if parts.next().is_some() {
|
|
return None;
|
|
}
|
|
let (hour, minute) = time.split_once(':')?;
|
|
if !(1..=2).contains(&hour.len())
|
|
|| minute.len() != 2
|
|
|| !hour
|
|
.bytes()
|
|
.chain(minute.bytes())
|
|
.all(|digit| digit.is_ascii_digit())
|
|
{
|
|
return None;
|
|
}
|
|
Some([format!("{hour:0>2}"), minute.to_owned()])
|
|
}
|
|
|
|
fn main() -> cosmic::iced::Result {
|
|
// Small shell surfaces do not need a second GPU device and shader setup.
|
|
// Keep wgpu as a fallback and honor an explicit renderer preference.
|
|
if std::env::var_os("ICED_BACKEND").is_none() {
|
|
// SAFETY: this is the process entry point, before any worker or
|
|
// toolkit threads are started.
|
|
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
|
|
}
|
|
cosmic::iced::advanced::graphics::text::font_system()
|
|
.write()
|
|
.unwrap()
|
|
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
|
|
"../../../assets/fonts/Comfortaa-Regular.otf"
|
|
)));
|
|
cosmic::iced::advanced::graphics::text::font_system()
|
|
.write()
|
|
.unwrap()
|
|
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
|
|
"../../../assets/fonts/Cantarell-ExtraBold.otf"
|
|
)));
|
|
let mut config = config::load();
|
|
motion::configure(config.animations);
|
|
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
|
|
if compositor_wallpaper {
|
|
// The compositor owns one GPU image, shared across output renderers;
|
|
// do not decode another copy or allocate full-screen software buffers.
|
|
config.wallpaper.path = None;
|
|
}
|
|
let shell_font = configured_font(config.font_family.as_deref());
|
|
let _ = SHELL_FONT.set(std::sync::RwLock::new(shell_font));
|
|
// Decode alongside toolkit/GPU initialization, never during a UI draw.
|
|
let wallpaper = config.wallpaper.path.clone().map(|path| {
|
|
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
|
|
std::thread::spawn(move || {
|
|
let result =
|
|
cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path))
|
|
.map(|pixels| {
|
|
image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw())
|
|
})
|
|
.map_err(|error| format!("{error:?}"));
|
|
let _ = sender.send(result);
|
|
});
|
|
receiver
|
|
});
|
|
let mut settings = Settings::default()
|
|
.no_main_window(true)
|
|
.client_decorations(false)
|
|
.transparent(true)
|
|
.is_daemon(true);
|
|
settings = settings.default_font(shell_font);
|
|
|
|
cosmic::app::run::<FereseShell>(settings, (config, wallpaper))
|
|
}
|
|
|
|
type WallpaperLoad =
|
|
cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
|
|
|
|
struct FereseShell {
|
|
core: Core,
|
|
bar_surface_id: window::Id,
|
|
config: ShellConfig,
|
|
wallpaper: Option<image::Handle>,
|
|
control: Option<ShellControl>,
|
|
snapshot: ShellSnapshot,
|
|
overview_active: bool,
|
|
clock: String,
|
|
desktop_clock: (String, String),
|
|
outputs: Vec<OutputSurfaces>,
|
|
notifications: notifications::Center,
|
|
notification_surface: Option<notification_ui::NotificationSurface>,
|
|
status_service: status::Service,
|
|
status: status::Snapshot,
|
|
status_error: Option<String>,
|
|
menu: Option<status_ui::OpenMenu>,
|
|
note_editor: Option<DesktopNoteEditor>,
|
|
note_drag: Option<NoteDrag>,
|
|
note_pointer: std::collections::HashMap<window::Id, cosmic::iced::Point>,
|
|
note_pending: Vec<note_store::Edit>,
|
|
note_inflight: Vec<note_store::Edit>,
|
|
note_saving: bool,
|
|
note_error: Option<String>,
|
|
}
|
|
|
|
struct DesktopNoteEditor {
|
|
id: String,
|
|
content: cosmic::widget::text_editor::Content<cosmic::Renderer>,
|
|
revision: u64,
|
|
}
|
|
|
|
struct NoteDrag {
|
|
id: Option<String>,
|
|
source: window::Id,
|
|
overlay: window::Id,
|
|
start: cosmic::iced::Point,
|
|
origin: cosmic::iced::Point,
|
|
position: cosmic::iced::Point,
|
|
output_size: (i32, i32),
|
|
obstacles: Vec<cosmic::iced::Rectangle>,
|
|
}
|
|
|
|
struct OutputSurfaces {
|
|
output: wl_output::WlOutput,
|
|
name: Option<String>,
|
|
bar: window::Id,
|
|
wallpaper: Option<window::Id>,
|
|
clock: Option<window::Id>,
|
|
notes: Vec<(String, window::Id)>,
|
|
size: Option<(i32, i32)>,
|
|
effects: Option<EffectsBinding>,
|
|
hidden: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum Message {
|
|
BeginNoteEdit(String),
|
|
NoteAction(String, cosmic::widget::text_editor::Action),
|
|
SaveNote(String, u64),
|
|
FinishNoteEdit,
|
|
BeginNoteDrag(String, window::Id),
|
|
BeginClockDrag(window::Id),
|
|
NotesStored(Result<(), String>),
|
|
WallpaperLoaded(Result<image::Handle, String>),
|
|
Event(Event, window::Id),
|
|
NativeSurface(
|
|
window::Id,
|
|
Result<(Connection, wl_surface::WlSurface), String>,
|
|
),
|
|
Tick,
|
|
ActivateWorkspace(u64),
|
|
ToggleOverview,
|
|
StatusUpdated(status::Update),
|
|
NotificationEvent(notifications::Event),
|
|
NotificationTick,
|
|
DismissNotification(u32),
|
|
RemoveNotification(u32),
|
|
InvokeNotification(u32, String),
|
|
HoverNotification(u32, bool),
|
|
ToggleNotificationHistory,
|
|
ClearNotifications,
|
|
AnimateMenu,
|
|
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
|
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
|
Control(status::Action),
|
|
ConfirmPower(status::Action),
|
|
CancelPower,
|
|
}
|
|
|
|
impl cosmic::Application for FereseShell {
|
|
type Executor = cosmic::executor::Default;
|
|
type Flags = (ShellConfig, Option<WallpaperLoad>);
|
|
type Message = Message;
|
|
|
|
const APP_ID: &'static str = APP_ID;
|
|
|
|
fn core(&self) -> &Core {
|
|
&self.core
|
|
}
|
|
|
|
fn core_mut(&mut self) -> &mut Core {
|
|
&mut self.core
|
|
}
|
|
|
|
fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) {
|
|
let bar_surface_id = window::Id::unique();
|
|
let desktop_clock = config
|
|
.desktop_widgets
|
|
.clock
|
|
.labels(&Zoned::now())
|
|
.unwrap_or_default();
|
|
let app = Self {
|
|
core,
|
|
bar_surface_id,
|
|
notifications: notifications::Center::new(config.notifications.clone()),
|
|
notification_surface: None,
|
|
status_service: status::Service::start(config.status.settings_command.clone()),
|
|
status: status::Snapshot::default(),
|
|
status_error: None,
|
|
menu: None,
|
|
note_editor: None,
|
|
note_drag: None,
|
|
note_pointer: Default::default(),
|
|
note_pending: Vec::new(),
|
|
note_inflight: Vec::new(),
|
|
note_saving: false,
|
|
note_error: None,
|
|
config,
|
|
wallpaper: None,
|
|
control: ShellControl::connect()
|
|
.map_err(|error| {
|
|
eprintln!("ferese-shell: shell control unavailable: {error}");
|
|
})
|
|
.ok(),
|
|
snapshot: ShellSnapshot::default(),
|
|
overview_active: false,
|
|
clock: current_time(),
|
|
desktop_clock,
|
|
outputs: Vec::new(),
|
|
};
|
|
let wallpaper_task = match wallpaper {
|
|
Some(receiver) => cosmic::task::future(async move {
|
|
cosmic::Action::App(Message::WallpaperLoaded(
|
|
receiver
|
|
.await
|
|
.unwrap_or_else(|error| Err(error.to_string())),
|
|
))
|
|
}),
|
|
None => Task::none(),
|
|
};
|
|
(app, wallpaper_task)
|
|
}
|
|
|
|
fn subscription(&self) -> Subscription<Self::Message> {
|
|
Subscription::batch([
|
|
self.notifications
|
|
.subscription()
|
|
.map(Message::NotificationEvent),
|
|
if self.notifications.has_toasts() {
|
|
cosmic::iced::time::every(Duration::from_millis(
|
|
if self.notifications.animating() {
|
|
16
|
|
} else {
|
|
250
|
|
},
|
|
))
|
|
.map(|_| Message::NotificationTick)
|
|
} else {
|
|
Subscription::none()
|
|
},
|
|
event::listen_with(|event, _status, id| match &event {
|
|
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => {
|
|
EFFECT_FRAME_PENDING
|
|
.load(Ordering::Relaxed)
|
|
.then_some(Message::Event(event, id))
|
|
}
|
|
Event::Keyboard(_)
|
|
| Event::Window(window::Event::Opened { .. } | window::Event::Closed)
|
|
| Event::PlatformSpecific(PlatformSpecific::Wayland(
|
|
wayland::Event::Popup(..)
|
|
| wayland::Event::Layer(..)
|
|
| wayland::Event::Output(..),
|
|
)) => Some(Message::Event(event, id)),
|
|
_ => None,
|
|
}),
|
|
cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick),
|
|
self.status_service
|
|
.subscription()
|
|
.map(Message::StatusUpdated),
|
|
if self.config.desktop_widgets.clock.enabled
|
|
|| self
|
|
.config
|
|
.desktop_widgets
|
|
.notes
|
|
.iter()
|
|
.any(|note| note.enabled && note.interactive)
|
|
{
|
|
event::listen_with(|event, _, id| {
|
|
matches!(&event, Event::Mouse(_)).then_some(Message::Event(event, id))
|
|
})
|
|
} else {
|
|
Subscription::none()
|
|
},
|
|
if self
|
|
.menu
|
|
.as_ref()
|
|
.is_some_and(|menu| menu.animating() || menu.motion.closing())
|
|
{
|
|
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
|
|
} else {
|
|
Subscription::none()
|
|
},
|
|
])
|
|
}
|
|
|
|
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
|
|
if let Some(menu) = &self.menu
|
|
&& let Some(effects) = &menu.effects
|
|
{
|
|
if let Err(error) = effects.set_opacity(menu.progress()) {
|
|
eprintln!("ferese-shell: could not update popup opacity: {error}");
|
|
}
|
|
let regions = menu.regions.lock().unwrap().clone();
|
|
if let Err(error) = effects.set_regions(®ions) {
|
|
eprintln!("ferese-shell: could not update card materials: {error}");
|
|
}
|
|
}
|
|
match message {
|
|
Message::BeginNoteEdit(id) => {
|
|
let mut tasks = vec![self.finish_note_edit()];
|
|
if let Some(note) = self
|
|
.config
|
|
.desktop_widgets
|
|
.notes
|
|
.iter()
|
|
.find(|note| note.id == id)
|
|
{
|
|
self.note_editor = Some(DesktopNoteEditor {
|
|
id,
|
|
content: cosmic::widget::text_editor::Content::with_text(¬e.text),
|
|
revision: 0,
|
|
});
|
|
}
|
|
tasks.push(Task::none());
|
|
Task::batch(tasks)
|
|
}
|
|
Message::FinishNoteEdit => self.finish_note_edit(),
|
|
Message::NoteAction(id, action) => {
|
|
if let Some(editor) = &mut self.note_editor
|
|
&& editor.id == id
|
|
{
|
|
let edited = action.is_edit();
|
|
editor.content.perform(action);
|
|
if edited {
|
|
editor.revision = editor.revision.wrapping_add(1);
|
|
let revision = editor.revision;
|
|
return cosmic::task::future(async move {
|
|
// iced executor is Tokio; do not spawn blocking sleep threads.
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
Message::SaveNote(id, revision)
|
|
});
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::SaveNote(id, revision) => {
|
|
if let Some(editor) = &self.note_editor
|
|
&& editor.id == id
|
|
&& editor.revision == revision
|
|
{
|
|
let value = editor.content.text();
|
|
self.set_note_text(&id, value)
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
Message::BeginNoteDrag(id, surface) => self.begin_note_drag(id, surface),
|
|
Message::BeginClockDrag(surface) => self.begin_widget_drag(None, surface),
|
|
Message::NotesStored(result) => {
|
|
self.note_saving = false;
|
|
match result {
|
|
Ok(()) => {
|
|
self.note_inflight.clear();
|
|
self.note_error = None;
|
|
self.flush_note_changes()
|
|
}
|
|
Err(error) => {
|
|
self.note_pending.splice(0..0, self.note_inflight.drain(..));
|
|
self.note_error = Some(error);
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
Message::WallpaperLoaded(result) => {
|
|
match result {
|
|
Ok(handle) => self.wallpaper = Some(handle),
|
|
Err(error) => eprintln!("ferese-shell: wallpaper unavailable: {error}"),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::NativeSurface(id, result) => {
|
|
match result {
|
|
Ok((_connection, surface))
|
|
if self.outputs.iter().any(|entry| entry.bar == id) =>
|
|
{
|
|
self.attach_effects(id, &surface)
|
|
}
|
|
Ok((_connection, surface)) => {
|
|
if let Some(menu) = &mut self.menu
|
|
&& menu.id == id
|
|
&& menu.effects.is_none()
|
|
{
|
|
match EffectsBinding::attach_role(
|
|
&surface,
|
|
menu.kind.material_role(),
|
|
menu.progress(),
|
|
) {
|
|
Ok(binding) => menu.effects = Some(binding),
|
|
Err(error) => {
|
|
eprintln!("ferese-shell: popover material unavailable: {error}")
|
|
}
|
|
}
|
|
if let Some(effects) = &menu.effects {
|
|
let _ = effects.set_opacity(menu.progress());
|
|
}
|
|
}
|
|
}
|
|
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::StatusUpdated(update) => {
|
|
if update.generation >= self.status_service.generation {
|
|
self.status = update.snapshot;
|
|
self.status_error = update.error;
|
|
}
|
|
self.notifications.tick();
|
|
if self.notifications.ready {
|
|
self.status.notifications = Some(status::Notifications {
|
|
count: self.notifications.unread(),
|
|
dnd: self.notifications.dnd,
|
|
});
|
|
}
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::NotificationEvent(event) => {
|
|
self.notifications.handle_event(event);
|
|
if self.notifications.ready {
|
|
self.status.notifications = Some(status::Notifications {
|
|
count: self.notifications.unread(),
|
|
dnd: self.notifications.dnd,
|
|
});
|
|
}
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::NotificationTick => {
|
|
self.notifications.tick();
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::DismissNotification(id) => {
|
|
self.notifications.close(id, 2);
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::RemoveNotification(id) => {
|
|
self.notifications.dismiss(id);
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::InvokeNotification(id, action) => {
|
|
self.notifications.invoke(id, action);
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::HoverNotification(id, hovered) => {
|
|
self.notifications.hover(id, hovered);
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::ToggleNotificationHistory => {
|
|
self.notifications.toggle_history();
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::ClearNotifications => {
|
|
self.notifications.clear();
|
|
self.sync_notification_surface()
|
|
}
|
|
Message::AnimateMenu => self.animate_menu(),
|
|
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
|
|
Message::OpenMenuOn(id, kind, anchor) => {
|
|
if kind == status_ui::Menu::Notifications && self.notifications.ready {
|
|
self.notifications.toggle_history();
|
|
return Task::batch([self.destroy_menu(), self.sync_notification_surface()]);
|
|
}
|
|
let hide_history = if self.notifications.history_open {
|
|
self.notifications.history_open = false;
|
|
self.sync_notification_surface()
|
|
} else {
|
|
Task::none()
|
|
};
|
|
if self.bar_surface_id != id {
|
|
let destroy = self.destroy_menu();
|
|
self.bar_surface_id = id;
|
|
let open = self.open_menu(kind, anchor);
|
|
Task::batch([hide_history, destroy, open])
|
|
} else {
|
|
let open = self.open_menu(kind, anchor);
|
|
Task::batch([hide_history, open])
|
|
}
|
|
}
|
|
Message::ConfirmPower(action) => {
|
|
if let Some(menu) = &mut self.menu {
|
|
menu.confirm = Some(action);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::CancelPower => {
|
|
if let Some(menu) = &mut self.menu {
|
|
menu.confirm = None;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::Control(action) => {
|
|
if self.notifications.ready {
|
|
match action {
|
|
status::Action::Dnd(value) => {
|
|
self.notifications.dnd = value;
|
|
self.status.notifications = Some(status::Notifications {
|
|
count: self.notifications.unread(),
|
|
dnd: value,
|
|
});
|
|
return Task::none();
|
|
}
|
|
status::Action::Notifications => {
|
|
self.notifications.toggle_history();
|
|
return Task::batch([
|
|
self.close_menu(),
|
|
self.sync_notification_surface(),
|
|
]);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
self.status_error = self.status_service.send(action.clone()).err();
|
|
if self.status_error.is_none() {
|
|
self.optimistic_status(&action);
|
|
}
|
|
if matches!(
|
|
action,
|
|
status::Action::Notifications | status::Action::Settings
|
|
) {
|
|
return self.close_menu();
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::Event(event, id) => self.handle_event(event, id),
|
|
Message::Tick => {
|
|
self.clock = current_time();
|
|
if self.config.desktop_widgets.clock.enabled {
|
|
self.desktop_clock = self
|
|
.config
|
|
.desktop_widgets
|
|
.clock
|
|
.labels(&Zoned::now())
|
|
.unwrap_or_default();
|
|
}
|
|
let mut reload_task = Task::none();
|
|
if let Some(control) = &self.control {
|
|
let poll = control.poll();
|
|
|
|
if poll.disconnected {
|
|
return cosmic::iced::exit();
|
|
}
|
|
if let Some(active) = poll.overview_active {
|
|
self.overview_active = active;
|
|
}
|
|
if let Some(source) = poll.config {
|
|
reload_task = self.reload_config(source);
|
|
}
|
|
if let Some(snapshot) = poll.snapshot {
|
|
self.snapshot = snapshot;
|
|
let mut tasks = vec![reload_task];
|
|
for entry in &mut self.outputs {
|
|
let output = self
|
|
.snapshot
|
|
.outputs
|
|
.iter()
|
|
.find(|output| Some(output.name.as_str()) == entry.name.as_deref())
|
|
.map(|output| output.id);
|
|
let hidden = output_bar_hidden(&self.snapshot, output);
|
|
if entry.hidden == hidden {
|
|
continue;
|
|
}
|
|
entry.hidden = hidden;
|
|
if let Some(effects) = &entry.effects {
|
|
if let Err(error) = effects.set_visible(!hidden) {
|
|
eprintln!(
|
|
"ferese-shell: could not update panel material: {error}"
|
|
);
|
|
}
|
|
}
|
|
tasks.push(set_input_zone(
|
|
entry.bar,
|
|
if hidden { Some(Vec::new()) } else { None },
|
|
));
|
|
}
|
|
if self.bar_hidden(self.bar_surface_id) {
|
|
tasks.push(self.destroy_menu());
|
|
}
|
|
return Task::batch(tasks);
|
|
}
|
|
}
|
|
reload_task
|
|
}
|
|
Message::ActivateWorkspace(id) => {
|
|
if let Some(control) = &self.control {
|
|
control.activate_workspace(id);
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::ToggleOverview => {
|
|
self.overview_active = !self.overview_active;
|
|
if let Some(control) = &self.control {
|
|
control.set_overview_active(self.overview_active);
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn style(&self) -> Option<cosmic::iced::theme::Style> {
|
|
// Shell surfaces own their backgrounds. Never inherit an opaque
|
|
// application clear color during startup or a system-theme change.
|
|
Some(shell_surface_style(self.config.theme))
|
|
}
|
|
|
|
fn view(&self) -> Element<'_, Self::Message> {
|
|
text("").into()
|
|
}
|
|
|
|
fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> {
|
|
text("").into()
|
|
}
|
|
}
|
|
|
|
impl FereseShell {
|
|
fn reload_config(&mut self, source: String) -> Task<Message> {
|
|
let mut config = match config::parse_source(&source) {
|
|
Ok(config) => config,
|
|
Err(error) => {
|
|
eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
|
|
return Task::none();
|
|
}
|
|
};
|
|
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
|
|
config.wallpaper.path = None;
|
|
}
|
|
if self.config.font_family != config.font_family {
|
|
*SHELL_FONT
|
|
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
|
|
.write()
|
|
.unwrap() = configured_font(config.font_family.as_deref());
|
|
}
|
|
motion::configure(config.animations);
|
|
if self.config.animations != config.animations
|
|
&& let Some(menu) = &mut self.menu
|
|
{
|
|
menu.motion.update_settings(config.animations);
|
|
}
|
|
self.status_service
|
|
.update_settings(config.status.settings_command.clone());
|
|
let old = self.config.theme;
|
|
let old_clock = &self.config.desktop_widgets.clock;
|
|
let old_notes = &self.config.desktop_widgets.notes;
|
|
let new_notes = &config.desktop_widgets.notes;
|
|
let notes_changed = old_notes.len() != new_notes.len()
|
|
|| old_notes
|
|
.iter()
|
|
.zip(new_notes)
|
|
.any(|(old, new)| !old.same_surface(new));
|
|
let new_clock = &config.desktop_widgets.clock;
|
|
let clock_changed = old_clock.enabled != new_clock.enabled
|
|
|| old_clock.outputs != new_clock.outputs
|
|
|| old_clock.anchor != new_clock.anchor
|
|
|| old_clock.width != new_clock.width
|
|
|| old_clock.height != new_clock.height
|
|
|| old_clock.margin_x != new_clock.margin_x
|
|
|| old_clock.margin_y != new_clock.margin_y;
|
|
let theme = config.theme;
|
|
let geometry_changed = old.bar_height != theme.bar_height
|
|
|| old.bar_margin_top != theme.bar_margin_top
|
|
|| old.bar_margin_horizontal != theme.bar_margin_horizontal
|
|
|| old.bar_window_gap != theme.bar_window_gap;
|
|
self.notifications.configure(config.notifications.clone());
|
|
self.config = config;
|
|
self.desktop_clock = self
|
|
.config
|
|
.desktop_widgets
|
|
.clock
|
|
.labels(&Zoned::now())
|
|
.unwrap_or_default();
|
|
let mut tasks = vec![if clock_changed {
|
|
self.rebuild_clocks(true)
|
|
} else {
|
|
Task::none()
|
|
}];
|
|
if clock_changed
|
|
&& self
|
|
.note_drag
|
|
.as_ref()
|
|
.is_some_and(|drag| drag.id.is_none())
|
|
{
|
|
tasks.push(self.finish_note_drag(false));
|
|
}
|
|
if notes_changed {
|
|
tasks.push(self.finish_note_drag(false));
|
|
if self.note_editor.as_ref().is_some_and(|editor| {
|
|
!self
|
|
.config
|
|
.desktop_widgets
|
|
.notes
|
|
.iter()
|
|
.any(|note| note.id == editor.id && note.interactive)
|
|
}) {
|
|
self.note_editor = None;
|
|
}
|
|
tasks.push(self.rebuild_notes(true));
|
|
}
|
|
for entry in self.outputs.iter().filter(|_| geometry_changed) {
|
|
tasks.push(set_size(
|
|
entry.bar,
|
|
None,
|
|
Some(theme.bar_height.round() as u32),
|
|
));
|
|
tasks.push(set_margin(
|
|
entry.bar,
|
|
theme.bar_margin_top,
|
|
theme.bar_margin_horizontal,
|
|
0,
|
|
theme.bar_margin_horizontal,
|
|
));
|
|
tasks.push(set_exclusive_zone(
|
|
entry.bar,
|
|
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap),
|
|
));
|
|
}
|
|
Task::batch(tasks)
|
|
}
|
|
|
|
fn output_event(
|
|
&mut self,
|
|
event: wayland::OutputEvent,
|
|
output: wl_output::WlOutput,
|
|
) -> Task<Message> {
|
|
if matches!(event, wayland::OutputEvent::Removed) {
|
|
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
|
|
let entry = self.outputs.remove(index);
|
|
let menu = if self.bar_surface_id == entry.bar {
|
|
self.destroy_menu()
|
|
} else {
|
|
Task::none()
|
|
};
|
|
let mut tasks = vec![destroy_layer_surface(entry.bar), menu];
|
|
if self.note_drag.as_ref().is_some_and(|drag| {
|
|
entry.clock == Some(drag.source)
|
|
|| entry.notes.iter().any(|(_, id)| *id == drag.source)
|
|
}) {
|
|
tasks.push(self.finish_note_drag(false));
|
|
}
|
|
if let Some(wallpaper) = entry.wallpaper {
|
|
tasks.push(destroy_layer_surface(wallpaper));
|
|
}
|
|
if let Some(clock) = entry.clock {
|
|
tasks.push(destroy_layer_surface(clock));
|
|
}
|
|
for (_, id) in entry.notes {
|
|
self.note_pointer.remove(&id);
|
|
tasks.push(destroy_layer_surface(id));
|
|
}
|
|
return Task::batch(tasks);
|
|
}
|
|
return Task::none();
|
|
}
|
|
let size = match &event {
|
|
wayland::OutputEvent::Created(info) => info.as_ref().and_then(|info| info.logical_size),
|
|
wayland::OutputEvent::InfoUpdate(info) => info.logical_size,
|
|
_ => None,
|
|
};
|
|
let name = match event {
|
|
wayland::OutputEvent::Created(info) => info.and_then(|info| info.name),
|
|
wayland::OutputEvent::InfoUpdate(info) => info.name,
|
|
_ => None,
|
|
};
|
|
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) {
|
|
if size.is_some() {
|
|
entry.size = size;
|
|
}
|
|
let changed = name.is_some() && entry.name != name;
|
|
if name.is_some() {
|
|
entry.name = name;
|
|
}
|
|
return if changed {
|
|
Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)])
|
|
} else {
|
|
Task::none()
|
|
};
|
|
}
|
|
let bar_surface_id = window::Id::unique();
|
|
let wallpaper_surface_id = window::Id::unique();
|
|
let shell_theme = self.config.theme;
|
|
let bar = BarMetrics::from(shell_theme);
|
|
let wallpaper_output = output.clone();
|
|
let bar_output = output.clone();
|
|
let hidden = output_bar_hidden(
|
|
&self.snapshot,
|
|
self.snapshot
|
|
.outputs
|
|
.iter()
|
|
.find(|output| Some(output.name.as_str()) == name.as_deref())
|
|
.map(|output| output.id),
|
|
);
|
|
self.outputs.push(OutputSurfaces {
|
|
output,
|
|
name,
|
|
bar: bar_surface_id,
|
|
wallpaper: std::env::var_os("FERESE_COMPOSITOR_WALLPAPER")
|
|
.is_none()
|
|
.then_some(wallpaper_surface_id),
|
|
effects: None,
|
|
clock: None,
|
|
notes: Vec::new(),
|
|
size,
|
|
hidden,
|
|
});
|
|
let wallpaper_action = cosmic::surface::action::app_layer_shell::<Self>(
|
|
|_| Default::default(),
|
|
move |_| SctkLayerSurfaceSettings {
|
|
id: wallpaper_surface_id,
|
|
layer: Layer::Background,
|
|
keyboard_interactivity: KeyboardInteractivity::None,
|
|
input_zone: Some(Vec::new()),
|
|
anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT,
|
|
output: IcedOutput::Output(wallpaper_output.clone()),
|
|
namespace: "ferese-shell-wallpaper".to_owned(),
|
|
// Wallpaper covers the full output, including beneath the bar
|
|
// and its floating margins. Zero would use the remaining workspace.
|
|
exclusive_zone: -1,
|
|
size: Some((None, None)),
|
|
size_limits: Limits::NONE,
|
|
..Default::default()
|
|
},
|
|
Some(Box::new(Self::view_wallpaper)),
|
|
);
|
|
let bar_action = cosmic::surface::action::app_layer_shell::<Self>(
|
|
|_| Default::default(),
|
|
move |_| SctkLayerSurfaceSettings {
|
|
id: bar_surface_id,
|
|
input_zone: hidden.then(Vec::new),
|
|
layer: Layer::Top,
|
|
keyboard_interactivity: KeyboardInteractivity::None,
|
|
anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT,
|
|
output: IcedOutput::Output(bar_output.clone()),
|
|
namespace: "ferese-shell-top-bar".to_owned(),
|
|
margin: IcedMargin {
|
|
top: shell_theme.bar_margin_top,
|
|
right: shell_theme.bar_margin_horizontal,
|
|
bottom: 0,
|
|
left: shell_theme.bar_margin_horizontal,
|
|
},
|
|
size: Some((None, Some(bar.height.round() as u32))),
|
|
size_limits: Limits::NONE,
|
|
// Reserve breathing room below the visible bar; layer-shell
|
|
// accounts for the top margin separately.
|
|
exclusive_zone: (bar.height.round() as i32)
|
|
.saturating_add(shell_theme.bar_window_gap),
|
|
..Default::default()
|
|
},
|
|
Some(Box::new(move |app| app.view_layer(bar_surface_id))),
|
|
);
|
|
|
|
// Submit the small interactive surface before the full-screen image.
|
|
let mut surfaces = vec![bar_action];
|
|
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() {
|
|
surfaces.push(wallpaper_action);
|
|
}
|
|
let tasks = surfaces
|
|
.into_iter()
|
|
.map(cosmic::Action::Surface)
|
|
.map(cosmic::task::message);
|
|
|
|
Task::batch([
|
|
Task::batch(tasks),
|
|
self.rebuild_clocks(false),
|
|
self.rebuild_notes(false),
|
|
])
|
|
}
|
|
|
|
fn flush_note_changes(&mut self) -> Task<Message> {
|
|
if self.note_saving || self.note_pending.is_empty() {
|
|
return Task::none();
|
|
}
|
|
let Some(path) = config::config_path() else {
|
|
self.note_error = Some("Configuration path unavailable.".into());
|
|
return Task::none();
|
|
};
|
|
self.note_saving = true;
|
|
self.note_inflight = std::mem::take(&mut self.note_pending);
|
|
let edits = self.note_inflight.clone();
|
|
cosmic::task::future(async move {
|
|
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
|
|
std::thread::spawn(move || {
|
|
let _ = send.send(note_store::save(&path, &edits));
|
|
});
|
|
Message::NotesStored(
|
|
receive
|
|
.await
|
|
.unwrap_or_else(|_| Err("Note save worker stopped.".into())),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn set_note_text(&mut self, id: &str, value: String) -> Task<Message> {
|
|
if let Some(note) = self
|
|
.config
|
|
.desktop_widgets
|
|
.notes
|
|
.iter_mut()
|
|
.find(|note| note.id == id)
|
|
{
|
|
if note.text != value || self.note_error.is_some() {
|
|
note.text = value.clone();
|
|
self.note_pending
|
|
.push(note_store::Edit::Text(id.to_owned(), value));
|
|
}
|
|
}
|
|
self.flush_note_changes()
|
|
}
|
|
|
|
fn finish_note_edit(&mut self) -> Task<Message> {
|
|
if let Some(editor) = self.note_editor.take() {
|
|
self.set_note_text(&editor.id, editor.content.text())
|
|
} else {
|
|
self.flush_note_changes()
|
|
}
|
|
}
|
|
|
|
fn begin_note_drag(&mut self, id: String, surface: window::Id) -> Task<Message> {
|
|
self.begin_widget_drag(Some(id), surface)
|
|
}
|
|
|
|
fn begin_widget_drag(&mut self, id: Option<String>, surface: window::Id) -> Task<Message> {
|
|
if self.note_drag.is_some() {
|
|
return Task::none();
|
|
}
|
|
let Some(entry) = self.outputs.iter().find(|entry| {
|
|
entry.clock == Some(surface) || entry.notes.iter().any(|(_, window)| *window == surface)
|
|
}) else {
|
|
return Task::none();
|
|
};
|
|
let Some(size) = entry.size else {
|
|
return Task::none();
|
|
};
|
|
let (origin, dimensions) = if let Some(id) = &id {
|
|
let Some(note) = self
|
|
.config
|
|
.desktop_widgets
|
|
.notes
|
|
.iter()
|
|
.find(|note| ¬e.id == id)
|
|
else {
|
|
return Task::none();
|
|
};
|
|
(note_origin(note, size), (note.width, note.height))
|
|
} else {
|
|
let clock = &self.config.desktop_widgets.clock;
|
|
(
|
|
widget_origin(
|
|
clock.anchor,
|
|
clock.margin_x,
|
|
clock.margin_y,
|
|
(clock.width, clock.height),
|
|
size,
|
|
),
|
|
(clock.width, clock.height),
|
|
)
|
|
};
|
|
let output = entry.output.clone();
|
|
let mut obstacles = Vec::new();
|
|
let clock = &self.config.desktop_widgets.clock;
|
|
if id.is_some() && clock.on_output(entry.name.as_deref()) {
|
|
let position = widget_origin(
|
|
clock.anchor,
|
|
clock.margin_x,
|
|
clock.margin_y,
|
|
(clock.width, clock.height),
|
|
size,
|
|
);
|
|
obstacles.push(cosmic::iced::Rectangle::new(
|
|
position,
|
|
cosmic::iced::Size::new(clock.width as f32, clock.height as f32),
|
|
));
|
|
}
|
|
for note in &self.config.desktop_widgets.notes {
|
|
if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) {
|
|
obstacles.push(cosmic::iced::Rectangle::new(
|
|
note_origin(note, size),
|
|
cosmic::iced::Size::new(note.width as f32, note.height as f32),
|
|
));
|
|
}
|
|
}
|
|
let overlay = window::Id::unique();
|
|
self.note_drag = Some(NoteDrag {
|
|
id,
|
|
source: surface,
|
|
overlay,
|
|
origin,
|
|
position: origin,
|
|
start: self.note_pointer.get(&surface).copied().unwrap_or_default(),
|
|
output_size: size,
|
|
obstacles,
|
|
});
|
|
let action = cosmic::surface::action::app_layer_shell::<Self>(
|
|
|_| Default::default(),
|
|
move |_| SctkLayerSurfaceSettings {
|
|
id: overlay,
|
|
layer: Layer::Overlay,
|
|
keyboard_interactivity: KeyboardInteractivity::None,
|
|
input_zone: Some(Vec::new()),
|
|
anchor: Anchor::TOP | Anchor::LEFT,
|
|
margin: IcedMargin {
|
|
top: origin.y.round() as i32,
|
|
left: origin.x.round() as i32,
|
|
..Default::default()
|
|
},
|
|
output: IcedOutput::Output(output.clone()),
|
|
namespace: "ferese-shell-note-drag".into(),
|
|
exclusive_zone: -1,
|
|
size: Some((Some(dimensions.0), Some(dimensions.1))),
|
|
size_limits: Limits::NONE,
|
|
..Default::default()
|
|
},
|
|
Some(Box::new(Self::view_note_drag)),
|
|
);
|
|
Task::batch([
|
|
self.finish_note_edit(),
|
|
cosmic::task::message(cosmic::Action::Surface(action)),
|
|
])
|
|
}
|
|
|
|
fn view_note_drag(&self) -> Element<'_, cosmic::Action<Message>> {
|
|
let Some(drag) = &self.note_drag else {
|
|
return text("").into();
|
|
};
|
|
if let Some(id) = &drag.id {
|
|
self.view_note_content(id, drag.source)
|
|
} else {
|
|
self.view_clock_content()
|
|
}
|
|
}
|
|
|
|
fn finish_note_drag(&mut self, save: bool) -> Task<Message> {
|
|
let Some(drag) = self.note_drag.take() else {
|
|
return Task::none();
|
|
};
|
|
let mut tasks = vec![destroy_layer_surface(drag.overlay)];
|
|
if save {
|
|
if drag.id.is_none() {
|
|
let clock = &mut self.config.desktop_widgets.clock;
|
|
clock.anchor = ferese_core::desktop::Anchor::TopLeft;
|
|
clock.margin_x = drag.position.x.round() as i32;
|
|
clock.margin_y = drag.position.y.round() as i32;
|
|
for entry in &self.outputs {
|
|
if let Some(id) = entry.clock {
|
|
tasks.push(set_anchor(id, Anchor::TOP | Anchor::LEFT));
|
|
tasks.push(set_margin(id, clock.margin_y, 0, 0, clock.margin_x));
|
|
}
|
|
}
|
|
self.note_pending.push(note_store::Edit::ClockPosition(
|
|
clock.margin_x,
|
|
clock.margin_y,
|
|
));
|
|
}
|
|
if let Some(note) = self
|
|
.config
|
|
.desktop_widgets
|
|
.notes
|
|
.iter_mut()
|
|
.find(|note| Some(¬e.id) == drag.id.as_ref())
|
|
{
|
|
note.anchor = ferese_core::desktop::Anchor::TopLeft;
|
|
note.margin_x = drag.position.x.round() as i32;
|
|
note.margin_y = drag.position.y.round() as i32;
|
|
for entry in &self.outputs {
|
|
if let Some((_, id)) = entry
|
|
.notes
|
|
.iter()
|
|
.find(|(id, _)| Some(id) == drag.id.as_ref())
|
|
{
|
|
tasks.push(set_anchor(*id, Anchor::TOP | Anchor::LEFT));
|
|
tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x));
|
|
}
|
|
}
|
|
self.note_pending.push(note_store::Edit::Position(
|
|
note.id.clone(),
|
|
note.margin_x,
|
|
note.margin_y,
|
|
));
|
|
}
|
|
tasks.push(self.flush_note_changes());
|
|
}
|
|
Task::batch(tasks)
|
|
}
|
|
|
|
fn rebuild_notes(&mut self, reset: bool) -> Task<Message> {
|
|
let mut tasks = Vec::new();
|
|
for entry in &mut self.outputs {
|
|
if reset {
|
|
for (_, id) in entry.notes.drain(..) {
|
|
self.note_pointer.remove(&id);
|
|
tasks.push(destroy_layer_surface(id));
|
|
}
|
|
}
|
|
for note in &self.config.desktop_widgets.notes {
|
|
if !note.on_output(entry.name.as_deref())
|
|
|| entry.notes.iter().any(|(id, _)| id == ¬e.id)
|
|
{
|
|
continue;
|
|
}
|
|
let id = window::Id::unique();
|
|
entry.notes.push((note.id.clone(), id));
|
|
let note = note.clone();
|
|
let note_id = note.id.clone();
|
|
let output = entry.output.clone();
|
|
let action = cosmic::surface::action::app_layer_shell::<Self>(
|
|
|_| Default::default(),
|
|
move |_| {
|
|
let (anchor, margin) =
|
|
widget_placement(note.anchor, note.margin_x, note.margin_y);
|
|
SctkLayerSurfaceSettings {
|
|
id,
|
|
layer: Layer::Bottom,
|
|
keyboard_interactivity: if note.interactive {
|
|
KeyboardInteractivity::OnDemand
|
|
} else {
|
|
KeyboardInteractivity::None
|
|
},
|
|
input_zone: if note.interactive {
|
|
None
|
|
} else {
|
|
Some(Vec::new())
|
|
},
|
|
anchor,
|
|
margin,
|
|
output: IcedOutput::Output(output.clone()),
|
|
namespace: "ferese-shell-sticky-note".into(),
|
|
exclusive_zone: -1,
|
|
size: Some((Some(note.width), Some(note.height))),
|
|
size_limits: Limits::NONE,
|
|
..Default::default()
|
|
}
|
|
},
|
|
Some(Box::new(move |app| app.view_note(¬e_id, id))),
|
|
);
|
|
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
|
}
|
|
}
|
|
Task::batch(tasks)
|
|
}
|
|
|
|
fn view_note(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
|
if self
|
|
.note_drag
|
|
.as_ref()
|
|
.is_some_and(|drag| drag.id.as_deref() == Some(id))
|
|
{
|
|
return container(text(""))
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into();
|
|
}
|
|
self.view_note_content(id, surface)
|
|
}
|
|
|
|
fn view_note_content(
|
|
&self,
|
|
id: &str,
|
|
surface: window::Id,
|
|
) -> Element<'_, cosmic::Action<Message>> {
|
|
let Some(note) = self
|
|
.config
|
|
.desktop_widgets
|
|
.notes
|
|
.iter()
|
|
.find(|note| note.id == id)
|
|
else {
|
|
return text("").into();
|
|
};
|
|
let alignment = match note.alignment {
|
|
ferese_core::desktop::Alignment::Left => alignment::Horizontal::Left,
|
|
ferese_core::desktop::Alignment::Center => alignment::Horizontal::Center,
|
|
ferese_core::desktop::Alignment::Right => alignment::Horizontal::Right,
|
|
};
|
|
let font = configured_font(
|
|
note.font_family
|
|
.as_deref()
|
|
.filter(|s| !s.trim().is_empty())
|
|
.or(self.config.font_family.as_deref()),
|
|
);
|
|
let mut foreground = color(
|
|
note.color
|
|
.as_deref()
|
|
.and_then(config::parse_color)
|
|
.unwrap_or(self.config.theme.text_primary),
|
|
);
|
|
foreground.a *= note.opacity;
|
|
let background = if note.background.as_deref() == Some("") {
|
|
None
|
|
} else {
|
|
let mut tint = color(
|
|
note.background
|
|
.as_deref()
|
|
.and_then(config::parse_color)
|
|
.unwrap_or(self.config.theme.surface_base),
|
|
);
|
|
tint.a *= note.opacity;
|
|
Some(Background::Color(tint))
|
|
};
|
|
let mut body = cosmic::widget::column([])
|
|
.spacing(note.gap)
|
|
.align_x(alignment);
|
|
if note.interactive {
|
|
let title = cosmic::widget::text(if note.title.is_empty() {
|
|
"⋮⋮"
|
|
} else {
|
|
¬e.title
|
|
})
|
|
.font(font)
|
|
.size(note.title_size)
|
|
.class(theme::Text::Color(foreground));
|
|
let header = cosmic::widget::mouse_area(container(title).width(Length::Fill))
|
|
.interaction(cosmic::iced::mouse::Interaction::Grab)
|
|
.on_press(cosmic::Action::App(Message::BeginNoteDrag(
|
|
id.to_owned(),
|
|
surface,
|
|
)));
|
|
let editing = self
|
|
.note_editor
|
|
.as_ref()
|
|
.is_some_and(|editor| editor.id == id);
|
|
body = body.push(row([]).push(header).push(
|
|
button::text(if editing { "Done" } else { "Edit" }).on_press(cosmic::Action::App(
|
|
if editing {
|
|
Message::FinishNoteEdit
|
|
} else {
|
|
Message::BeginNoteEdit(id.to_owned())
|
|
},
|
|
)),
|
|
));
|
|
} else if !note.title.is_empty() {
|
|
body = body.push(
|
|
cosmic::widget::text(note.title.clone())
|
|
.font(cosmic::font::Font {
|
|
weight: cosmic::iced::font::Weight::Semibold,
|
|
..font
|
|
})
|
|
.size(note.title_size)
|
|
.width(Length::Fill)
|
|
.align_x(alignment)
|
|
.class(theme::Text::Color(foreground)),
|
|
);
|
|
}
|
|
if let Some(editor) = &self.note_editor
|
|
&& editor.id == id
|
|
{
|
|
let id = id.to_owned();
|
|
body = body.push(
|
|
cosmic::widget::TextEditor::new(&editor.content)
|
|
.height(Length::Fill)
|
|
.font(font)
|
|
.size(note.text_size)
|
|
.on_action(move |action| {
|
|
cosmic::Action::App(Message::NoteAction(id.clone(), action))
|
|
}),
|
|
);
|
|
} else {
|
|
body = body.push(
|
|
cosmic::widget::text(note.text.clone())
|
|
.font(font)
|
|
.size(note.text_size)
|
|
.width(Length::Fill)
|
|
.align_x(alignment)
|
|
.class(theme::Text::Color(foreground)),
|
|
);
|
|
}
|
|
if let Some(error) = &self.note_error {
|
|
body = body.push(
|
|
text(error.clone())
|
|
.size(10)
|
|
.class(theme::Text::Color(foreground)),
|
|
);
|
|
}
|
|
let radius = note.radius;
|
|
container(body)
|
|
.padding(note.padding)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.class(theme::Container::custom(move |_| container::Style {
|
|
background,
|
|
border: Border {
|
|
radius: radius.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}))
|
|
.into()
|
|
}
|
|
|
|
fn rebuild_clocks(&mut self, reset: bool) -> Task<Message> {
|
|
self.desktop_clock = self
|
|
.config
|
|
.desktop_widgets
|
|
.clock
|
|
.labels(&Zoned::now())
|
|
.unwrap_or_default();
|
|
let mut tasks = Vec::new();
|
|
for entry in &mut self.outputs {
|
|
if !reset && entry.clock.is_some() {
|
|
continue;
|
|
}
|
|
if let Some(id) = entry.clock.take() {
|
|
self.note_pointer.remove(&id);
|
|
tasks.push(destroy_layer_surface(id));
|
|
}
|
|
let clock = self.config.desktop_widgets.clock.clone();
|
|
if !clock.on_output(entry.name.as_deref()) {
|
|
continue;
|
|
}
|
|
let id = window::Id::unique();
|
|
entry.clock = Some(id);
|
|
let output = entry.output.clone();
|
|
let action = cosmic::surface::action::app_layer_shell::<Self>(
|
|
|_| Default::default(),
|
|
move |_| {
|
|
let (anchor, margin) = clock_placement(&clock);
|
|
SctkLayerSurfaceSettings {
|
|
id,
|
|
layer: Layer::Bottom,
|
|
keyboard_interactivity: KeyboardInteractivity::OnDemand,
|
|
input_zone: None,
|
|
anchor,
|
|
margin,
|
|
output: IcedOutput::Output(output.clone()),
|
|
namespace: "ferese-shell-desktop-clock".into(),
|
|
exclusive_zone: -1,
|
|
size: Some((Some(clock.width), Some(clock.height))),
|
|
size_limits: Limits::NONE,
|
|
..Default::default()
|
|
}
|
|
},
|
|
Some(Box::new(move |app| app.view_desktop_clock(id))),
|
|
);
|
|
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
|
}
|
|
Task::batch(tasks)
|
|
}
|
|
|
|
fn view_desktop_clock(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
|
if self
|
|
.note_drag
|
|
.as_ref()
|
|
.is_some_and(|drag| drag.id.is_none() && drag.source == id)
|
|
{
|
|
return container(text(""))
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into();
|
|
}
|
|
cosmic::widget::mouse_area(self.view_clock_content())
|
|
.on_press(cosmic::Action::App(Message::BeginClockDrag(id)))
|
|
.interaction(cosmic::iced::mouse::Interaction::Grab)
|
|
.into()
|
|
}
|
|
|
|
fn view_clock_content(&self) -> Element<'_, cosmic::Action<Message>> {
|
|
use ferese_core::desktop::Alignment as ClockAlignment;
|
|
let clock = &self.config.desktop_widgets.clock;
|
|
let alignment = match clock.alignment {
|
|
ClockAlignment::Left => alignment::Horizontal::Left,
|
|
ClockAlignment::Center => alignment::Horizontal::Center,
|
|
ClockAlignment::Right => alignment::Horizontal::Right,
|
|
};
|
|
let pixel = clock.style == ferese_core::desktop::ClockStyle::Pixel;
|
|
let custom_font = clock
|
|
.font_family
|
|
.as_deref()
|
|
.filter(|family| !family.trim().is_empty());
|
|
let mut font = configured_font(if pixel {
|
|
custom_font.or(Some("Cantarell"))
|
|
} else {
|
|
custom_font.or(self.config.font_family.as_deref())
|
|
});
|
|
if pixel && custom_font.is_none() {
|
|
font.weight = cosmic::iced::font::Weight::Black;
|
|
} else if clock.bold {
|
|
font.weight = cosmic::iced::font::Weight::Bold;
|
|
}
|
|
let date_font = configured_font(custom_font.or(self.config.font_family.as_deref()));
|
|
let tint = |custom: &Option<String>, fallback| {
|
|
let mut tint = color(
|
|
custom
|
|
.as_deref()
|
|
.and_then(config::parse_color)
|
|
.unwrap_or(fallback),
|
|
);
|
|
tint.a *= clock.opacity;
|
|
tint
|
|
};
|
|
let foreground = tint(&clock.color, self.config.theme.text_primary);
|
|
let accent = color(self.config.theme.accent);
|
|
let custom_color = clock
|
|
.color
|
|
.as_deref()
|
|
.and_then(config::parse_color)
|
|
.is_some();
|
|
let digit_colors = [0.8, 0.45].map(|text_mix| {
|
|
if custom_color {
|
|
return foreground;
|
|
}
|
|
Color {
|
|
r: foreground.r * text_mix + accent.r * (1.0 - text_mix),
|
|
g: foreground.g * text_mix + accent.g * (1.0 - text_mix),
|
|
b: foreground.b * text_mix + accent.b * (1.0 - text_mix),
|
|
a: foreground.a,
|
|
}
|
|
});
|
|
let mut labels = cosmic::widget::column([])
|
|
.spacing(clock.gap)
|
|
.align_x(alignment);
|
|
|
|
if pixel && clock.show_date {
|
|
labels = labels.push(
|
|
cosmic::widget::text(self.desktop_clock.1.clone())
|
|
.font(date_font)
|
|
.size(clock.date_size)
|
|
.class(theme::Text::Color(tint(
|
|
&clock.date_color,
|
|
self.config.theme.text_primary,
|
|
))),
|
|
);
|
|
}
|
|
|
|
if let Some(digits) = pixel
|
|
.then(|| stacked_clock_digits(&self.desktop_clock.0))
|
|
.flatten()
|
|
{
|
|
let available_height = (clock.height as f32
|
|
- clock.padding * 2.0
|
|
- if clock.show_date {
|
|
clock.date_size * 1.3 + clock.gap
|
|
} else {
|
|
0.0
|
|
})
|
|
.max(16.0);
|
|
let size = clock
|
|
.time_size
|
|
.min(available_height / 1.6)
|
|
.min((clock.width as f32 - clock.padding * 2.0).max(16.0) / 1.5);
|
|
let mut stack = cosmic::widget::column([]).spacing(0).align_x(alignment);
|
|
|
|
for (line, digits) in digits.into_iter().enumerate() {
|
|
let mut digit_row = row([]).spacing(0);
|
|
for (position, digit) in digits.chars().enumerate() {
|
|
digit_row = digit_row.push(
|
|
cosmic::widget::text(digit.to_string())
|
|
.font(font)
|
|
.size(size)
|
|
.line_height(cosmic::iced::widget::text::LineHeight::Relative(0.8))
|
|
.class(theme::Text::Color(digit_colors[(line + position) % 2])),
|
|
);
|
|
}
|
|
stack = stack.push(digit_row);
|
|
}
|
|
labels = labels.push(stack);
|
|
} else {
|
|
let characters = self.desktop_clock.0.chars().count().max(1) as f32;
|
|
let available_width = (clock.width as f32 - clock.padding * 2.0).max(1.0);
|
|
let available_height = (clock.height as f32
|
|
- clock.padding * 2.0
|
|
- if clock.show_date {
|
|
clock.date_size * 1.3 + clock.gap
|
|
} else {
|
|
0.0
|
|
})
|
|
.max(1.0);
|
|
let size = clock
|
|
.time_size
|
|
.min(available_width / (characters * 0.75))
|
|
.min(available_height / 1.3);
|
|
labels = labels.push(
|
|
cosmic::widget::text(self.desktop_clock.0.clone())
|
|
.font(font)
|
|
.size(size)
|
|
.width(Length::Fill)
|
|
.align_x(alignment)
|
|
.class(theme::Text::Color(foreground)),
|
|
);
|
|
}
|
|
|
|
if !pixel && clock.show_date {
|
|
labels = labels.push(
|
|
cosmic::widget::text(self.desktop_clock.1.clone())
|
|
.font(font)
|
|
.size(clock.date_size)
|
|
.width(Length::Fill)
|
|
.align_x(alignment)
|
|
.class(theme::Text::Color(tint(
|
|
&clock.date_color,
|
|
self.config.theme.text_muted,
|
|
))),
|
|
);
|
|
}
|
|
let background = clock
|
|
.background
|
|
.as_deref()
|
|
.and_then(config::parse_color)
|
|
.map(|rgba| {
|
|
let mut tint = color(rgba);
|
|
tint.a *= clock.opacity;
|
|
Background::Color(tint)
|
|
});
|
|
let radius = clock.radius;
|
|
container(labels)
|
|
.padding(clock.padding)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.align_x(alignment)
|
|
.center_y(Length::Fill)
|
|
.class(theme::Container::custom(move |_| container::Style {
|
|
background,
|
|
border: Border {
|
|
radius: radius.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}))
|
|
.into()
|
|
}
|
|
|
|
fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> {
|
|
let name = self
|
|
.outputs
|
|
.iter()
|
|
.find(|entry| entry.bar == id)?
|
|
.name
|
|
.as_deref()?;
|
|
self.snapshot
|
|
.outputs
|
|
.iter()
|
|
.find(|output| output.name == name)
|
|
}
|
|
|
|
fn bar_hidden(&self, id: window::Id) -> bool {
|
|
output_bar_hidden(
|
|
&self.snapshot,
|
|
self.output_for_bar(id).map(|output| output.id),
|
|
)
|
|
}
|
|
|
|
fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
|
|
if let Event::Mouse(mouse) = &event {
|
|
if !self.outputs.iter().any(|entry| {
|
|
entry.clock == Some(id) || entry.notes.iter().any(|(_, surface)| *surface == id)
|
|
}) {
|
|
return Task::none();
|
|
}
|
|
match mouse {
|
|
cosmic::iced::mouse::Event::CursorMoved { position } => {
|
|
self.note_pointer.insert(id, *position);
|
|
if let Some(drag) = &mut self.note_drag
|
|
&& drag.source == id
|
|
{
|
|
let delta = *position - drag.start;
|
|
let dimensions = if let Some(id) = &drag.id {
|
|
self.config
|
|
.desktop_widgets
|
|
.notes
|
|
.iter()
|
|
.find(|note| ¬e.id == id)
|
|
.map(|note| (note.width, note.height))
|
|
} else {
|
|
let clock = &self.config.desktop_widgets.clock;
|
|
Some((clock.width, clock.height))
|
|
};
|
|
if let Some(dimensions) = dimensions {
|
|
let requested = clamp_note_position(
|
|
drag.origin + delta,
|
|
drag.output_size,
|
|
dimensions,
|
|
);
|
|
drag.position = avoid_widget_overlap(
|
|
drag.position,
|
|
requested,
|
|
dimensions,
|
|
&drag.obstacles,
|
|
);
|
|
// Move a compact, cached buffer in the compositor instead of
|
|
// repainting an output-sized buffer for every pointer event.
|
|
return set_margin(
|
|
drag.overlay,
|
|
drag.position.y.round() as i32,
|
|
0,
|
|
0,
|
|
drag.position.x.round() as i32,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) => {
|
|
if self
|
|
.note_drag
|
|
.as_ref()
|
|
.is_some_and(|drag| drag.source == id)
|
|
{
|
|
return self.finish_note_drag(true);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if matches!(
|
|
&event,
|
|
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
|
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
|
..
|
|
})
|
|
) {
|
|
if self.note_drag.is_some() {
|
|
return self.finish_note_drag(false);
|
|
}
|
|
if self.note_editor.is_some() {
|
|
return self.finish_note_edit();
|
|
}
|
|
if self.notifications.history_open {
|
|
self.notifications.history_open = false;
|
|
return self.sync_notification_surface();
|
|
}
|
|
}
|
|
match event {
|
|
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
|
|
event,
|
|
output,
|
|
))) => self.output_event(event, output),
|
|
Event::Window(window::Event::Opened { .. })
|
|
if self.outputs.iter().any(|entry| entry.bar == id)
|
|
|| self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
|
|
{
|
|
// Iced emits the first xdg_popup configure as Window::Opened,
|
|
// not Popup::Configured. This is the popup's ready signal.
|
|
if let Some(menu) = &mut self.menu
|
|
&& menu.id == id
|
|
{
|
|
menu.motion.begin(Instant::now());
|
|
if let Some(effects) = &menu.effects {
|
|
let _ = effects.set_opacity(menu.progress());
|
|
}
|
|
}
|
|
window::run(id, native_wayland_surface)
|
|
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
|
|
}
|
|
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
|
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
|
..
|
|
}) if self.menu.is_some() => self.close_menu(),
|
|
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
|
|
event,
|
|
surface,
|
|
popup_id,
|
|
))) => {
|
|
if let Some(menu) = &mut self.menu
|
|
&& menu.id == popup_id
|
|
{
|
|
match event {
|
|
wayland::PopupEvent::Done => {
|
|
self.menu = None;
|
|
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
|
return Task::none();
|
|
}
|
|
wayland::PopupEvent::Focused => {
|
|
if menu.effects.is_none() {
|
|
match EffectsBinding::attach_role(
|
|
&surface,
|
|
menu.kind.material_role(),
|
|
menu.progress(),
|
|
) {
|
|
Ok(binding) => menu.effects = Some(binding),
|
|
Err(error) => eprintln!(
|
|
"ferese-shell: popover material unavailable: {error}"
|
|
),
|
|
}
|
|
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
|
}
|
|
}
|
|
wayland::PopupEvent::Configured { .. } => {
|
|
menu.motion.begin(Instant::now());
|
|
if menu.effects.is_none() {
|
|
match EffectsBinding::attach_role(
|
|
&surface,
|
|
menu.kind.material_role(),
|
|
menu.progress(),
|
|
) {
|
|
Ok(binding) => menu.effects = Some(binding),
|
|
Err(error) => eprintln!(
|
|
"ferese-shell: popover material unavailable: {error}"
|
|
),
|
|
}
|
|
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
if let Some(effects) = &menu.effects {
|
|
let _ = effects.set_opacity(menu.progress());
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Event::Window(window::Event::Closed)
|
|
if self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
|
|
{
|
|
self.menu = None;
|
|
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
|
Task::none()
|
|
}
|
|
Event::Window(window::Event::Closed) => Task::none(),
|
|
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
|
|
_,
|
|
surface,
|
|
frame_id,
|
|
))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => {
|
|
if let Some(menu) = &mut self.menu {
|
|
if menu.effects.is_none() {
|
|
match EffectsBinding::attach_role(
|
|
&surface,
|
|
menu.kind.material_role(),
|
|
menu.progress(),
|
|
) {
|
|
Ok(binding) => menu.effects = Some(binding),
|
|
Err(error) => {
|
|
eprintln!("ferese-shell: popover material unavailable: {error}")
|
|
}
|
|
}
|
|
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
|
|
_,
|
|
surface,
|
|
frame_id,
|
|
))) if self.outputs.iter().any(|entry| entry.bar == frame_id) => {
|
|
self.attach_effects(frame_id, &surface);
|
|
Task::none()
|
|
}
|
|
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
|
|
_,
|
|
surface,
|
|
layer_id,
|
|
))) if self.outputs.iter().any(|entry| entry.bar == layer_id) => {
|
|
self.attach_effects(layer_id, &surface);
|
|
Task::none()
|
|
}
|
|
_ => Task::none(),
|
|
}
|
|
}
|
|
|
|
fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
|
|
let hidden = self.bar_hidden(id);
|
|
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else {
|
|
return;
|
|
};
|
|
if entry.effects.is_some() {
|
|
return;
|
|
}
|
|
|
|
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
|
|
|
match EffectsBinding::attach(surface, !hidden) {
|
|
Ok(binding) => entry.effects = Some(binding),
|
|
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
|
|
}
|
|
}
|
|
|
|
fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
|
if self.bar_hidden(id) {
|
|
return container(text(""))
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into();
|
|
}
|
|
|
|
let focused_output = self.output_for_bar(id);
|
|
let shell_theme = self.config.theme.for_bar();
|
|
let bar = BarMetrics::from(shell_theme);
|
|
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
|
|
.spacing(3)
|
|
.align_y(cosmic::iced::Alignment::Center);
|
|
|
|
workspace_row = workspace_row.push(motion::button(
|
|
button::custom(overview_control(bar, color(shell_theme.accent)))
|
|
.height(bar.control_height)
|
|
.padding([0, 7])
|
|
.on_press(cosmic::Action::App(Message::ToggleOverview)),
|
|
color(shell_theme.text_primary),
|
|
self.overview_active,
|
|
1.0,
|
|
));
|
|
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
|
|
.spacing(1)
|
|
.align_y(cosmic::iced::Alignment::Center);
|
|
for workspace in &self.snapshot.workspaces {
|
|
let active = workspace_active_on_bar(workspace.id, focused_output);
|
|
let owner = workspace
|
|
.output
|
|
.and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id));
|
|
let occupied = self
|
|
.snapshot
|
|
.windows
|
|
.iter()
|
|
.any(|window| window.workspace == workspace.id);
|
|
let indicator = workspace_indicator(
|
|
&workspace.name,
|
|
active,
|
|
workspace.active && !active,
|
|
occupied,
|
|
bar,
|
|
shell_theme,
|
|
);
|
|
let workspace_button = button::custom(indicator)
|
|
.name(format!(
|
|
"Workspace {}{}{}",
|
|
workspace.name,
|
|
if active { ", active" } else { "" },
|
|
owner.map_or(String::new(), |output| format!(", on {}", output.name))
|
|
))
|
|
.height(bar.control_height)
|
|
.padding(0)
|
|
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
|
|
workspace.id,
|
|
)));
|
|
|
|
let selector = motion::button(
|
|
workspace_button,
|
|
color(if active {
|
|
shell_theme.accent
|
|
} else {
|
|
shell_theme.text_primary
|
|
}),
|
|
false,
|
|
1.0,
|
|
);
|
|
// Iced tooltips are overlays inside this bar-height layer surface;
|
|
// viewport clamping puts them back over the selector. Keep the
|
|
// accessible button name, without an overlay stealing its target.
|
|
workspace_buttons = workspace_buttons.push(selector);
|
|
}
|
|
workspace_row = workspace_row.push(container(workspace_buttons).padding([0, 3]).class(
|
|
theme::Container::custom(move |_| bar_group_style(shell_theme)),
|
|
));
|
|
|
|
let foreground = color(shell_theme.text_primary);
|
|
let left = cosmic::iced::widget::scrollable(workspace_row)
|
|
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
|
cosmic::iced::widget::scrollable::Scrollbar::default()
|
|
.width(0)
|
|
.scroller_width(0),
|
|
))
|
|
.width(Length::Fill)
|
|
.height(bar.control_height);
|
|
let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock));
|
|
let clock = container(
|
|
row![
|
|
text(date)
|
|
.size(12)
|
|
.class(theme::Text::Color(color(shell_theme.text_muted))),
|
|
text(time)
|
|
.size(bar.text_size)
|
|
.class(theme::Text::Color(foreground)),
|
|
]
|
|
.spacing(8)
|
|
.align_y(cosmic::iced::Alignment::Center),
|
|
)
|
|
.height(bar.control_height)
|
|
.align_y(alignment::Vertical::Center)
|
|
.padding([0, 8])
|
|
.class(theme::Container::custom(move |_| {
|
|
bar_group_style(shell_theme)
|
|
}));
|
|
let right = row![
|
|
self.view_status_bar().map(move |action| match action {
|
|
cosmic::Action::App(Message::OpenMenu(kind, anchor)) =>
|
|
cosmic::Action::App(Message::OpenMenuOn(id, kind, anchor)),
|
|
other => other,
|
|
}),
|
|
clock
|
|
]
|
|
.spacing(8)
|
|
.align_y(cosmic::iced::Alignment::Center);
|
|
let content = row![
|
|
container(left).width(Length::Fill),
|
|
container(right).width(Length::Shrink),
|
|
]
|
|
.spacing(16)
|
|
.align_y(cosmic::iced::Alignment::Center)
|
|
.height(Length::Fill);
|
|
|
|
let compositor_material = self
|
|
.outputs
|
|
.iter()
|
|
.any(|entry| entry.bar == id && entry.effects.is_some());
|
|
container(content)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.padding([0, shell_theme.panel_padding.round() as u16])
|
|
.class(theme::Container::custom(move |_| {
|
|
bar_style(shell_theme, compositor_material)
|
|
}))
|
|
.into()
|
|
}
|
|
|
|
fn view_wallpaper(&self) -> Element<'_, cosmic::Action<Message>> {
|
|
let Some(handle) = self.wallpaper.as_ref() else {
|
|
return container(text(""))
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.class(theme::Container::custom(wallpaper_fallback_style))
|
|
.into();
|
|
};
|
|
let content_fit = match self.config.wallpaper.mode {
|
|
WallpaperMode::Fill => ContentFit::Cover,
|
|
WallpaperMode::Fit => ContentFit::Contain,
|
|
};
|
|
|
|
container(
|
|
image(handle.clone())
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.content_fit(content_fit),
|
|
)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.class(theme::Container::custom(wallpaper_fallback_style))
|
|
.into()
|
|
}
|
|
}
|
|
|
|
fn bar_icon(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon {
|
|
accented_icon(source, size, foreground, foreground)
|
|
}
|
|
|
|
fn accented_icon(source: &'static [u8], size: u16, foreground: Color, accent: Color) -> icon::Icon {
|
|
let svg = std::str::from_utf8(source).expect("embedded SVG must be UTF-8");
|
|
// Battery canvases are wider; reserve that space instead of stretching
|
|
// or clipping them into the square slot used by the other status icons.
|
|
let aspect = if svg.contains("viewBox=\"0 0 32 24\"") {
|
|
4.0 / 3.0
|
|
} else {
|
|
1.0
|
|
};
|
|
icon::from_svg_bytes(tinted_svg(source, foreground, accent))
|
|
.symbolic(false)
|
|
.icon()
|
|
.size(size)
|
|
.width(Length::Fixed(f32::from(size) * aspect))
|
|
.content_fit(ContentFit::Contain)
|
|
.class(theme::Svg::custom(move |_| {
|
|
cosmic::iced::widget::svg::Style { color: None }
|
|
}))
|
|
}
|
|
|
|
fn tinted_svg(source: &[u8], foreground: Color, accent: Color) -> Vec<u8> {
|
|
let rgba = |foreground: Color| {
|
|
format!(
|
|
"rgba({},{},{},{})",
|
|
(foreground.r * 255.0).round() as u8,
|
|
(foreground.g * 255.0).round() as u8,
|
|
(foreground.b * 255.0).round() as u8,
|
|
foreground.a,
|
|
)
|
|
};
|
|
std::str::from_utf8(source)
|
|
.expect("embedded icons must be UTF-8 SVG")
|
|
.replace("currentColor", &rgba(foreground))
|
|
// The source artwork's blue swatch is the semantic theme accent.
|
|
// Battery warning/success swatches remain status colors.
|
|
.replace("#3d7be6", &rgba(accent))
|
|
.into_bytes()
|
|
}
|
|
|
|
fn overview_control(
|
|
bar: BarMetrics,
|
|
foreground: Color,
|
|
) -> Element<'static, cosmic::Action<Message>> {
|
|
container(bar_icon(
|
|
include_bytes!("../assets/icons/ferese.svg"),
|
|
bar.overview_icon_size,
|
|
foreground,
|
|
))
|
|
.width(bar.overview_icon_size)
|
|
.height(bar.control_height)
|
|
.align_x(alignment::Horizontal::Center)
|
|
.align_y(alignment::Vertical::Center)
|
|
.into()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn bar_hidden(snapshot: &ShellSnapshot) -> bool {
|
|
let output = snapshot
|
|
.outputs
|
|
.iter()
|
|
.find(|output| output.focused)
|
|
.or_else(|| snapshot.outputs.first())
|
|
.map(|output| output.id);
|
|
output_bar_hidden(snapshot, output)
|
|
}
|
|
|
|
fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -> bool {
|
|
let active_workspace = snapshot
|
|
.outputs
|
|
.iter()
|
|
.find(|candidate| Some(candidate.id) == output)
|
|
.map(|output| output.active_workspace);
|
|
|
|
active_workspace.is_some_and(|workspace| {
|
|
snapshot
|
|
.windows
|
|
.iter()
|
|
.any(|window| window.workspace == workspace && window.fullscreen)
|
|
})
|
|
}
|
|
|
|
fn workspace_indicator(
|
|
name: &str,
|
|
active: bool,
|
|
active_elsewhere: bool,
|
|
occupied: bool,
|
|
bar: BarMetrics,
|
|
shell_theme: ShellTheme,
|
|
) -> Element<'static, cosmic::Action<Message>> {
|
|
let foreground = if active {
|
|
shell_theme.accent
|
|
} else if occupied {
|
|
shell_theme.text_primary
|
|
} else {
|
|
shell_theme.text_muted
|
|
};
|
|
container(
|
|
text(name.to_owned())
|
|
.size(12)
|
|
.class(theme::Text::Color(color(foreground))),
|
|
)
|
|
.width(24)
|
|
.height(bar.control_height)
|
|
.align_x(alignment::Horizontal::Center)
|
|
.align_y(alignment::Vertical::Center)
|
|
.class(theme::Container::custom(move |_| {
|
|
workspace_selector_style(active, active_elsewhere, occupied, shell_theme)
|
|
}))
|
|
.into()
|
|
}
|
|
|
|
fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool {
|
|
output.is_some_and(|output| output.active_workspace == workspace)
|
|
}
|
|
|
|
fn workspace_selector_style(
|
|
active: bool,
|
|
active_elsewhere: bool,
|
|
occupied: bool,
|
|
shell_theme: ShellTheme,
|
|
) -> container::Style {
|
|
container::Style {
|
|
background: if active {
|
|
Some(Background::Color(color_with_opacity(
|
|
shell_theme.accent,
|
|
0.16,
|
|
)))
|
|
} else if occupied {
|
|
Some(Background::Color(color_with_opacity(
|
|
shell_theme.text_primary,
|
|
0.04,
|
|
)))
|
|
} else {
|
|
None
|
|
},
|
|
border: Border {
|
|
color: color_with_opacity(shell_theme.accent, 0.55),
|
|
width: if active_elsewhere { 1.0 } else { 0.0 },
|
|
radius: 6.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Sweep each axis, allowing edge sliding without tunnelling through a widget
|
|
/// when pointer events skip ahead. Coordinates match integer layer margins.
|
|
fn avoid_widget_overlap(
|
|
previous: cosmic::iced::Point,
|
|
requested: cosmic::iced::Point,
|
|
size: (u32, u32),
|
|
obstacles: &[cosmic::iced::Rectangle],
|
|
) -> cosmic::iced::Point {
|
|
let mut position = previous;
|
|
let (width, height) = (size.0 as f32, size.1 as f32);
|
|
let mut x = requested.x.round();
|
|
for obstacle in obstacles {
|
|
if position.y < obstacle.y + obstacle.height && position.y + height > obstacle.y {
|
|
if x > position.x && position.x + width <= obstacle.x {
|
|
x = x.min((obstacle.x - width).floor());
|
|
} else if x < position.x && position.x >= obstacle.x + obstacle.width {
|
|
x = x.max((obstacle.x + obstacle.width).ceil());
|
|
}
|
|
}
|
|
}
|
|
position.x = x;
|
|
let mut y = requested.y.round();
|
|
for obstacle in obstacles {
|
|
if position.x < obstacle.x + obstacle.width && position.x + width > obstacle.x {
|
|
if y > position.y && position.y + height <= obstacle.y {
|
|
y = y.min((obstacle.y - height).floor());
|
|
} else if y < position.y && position.y >= obstacle.y + obstacle.height {
|
|
y = y.max((obstacle.y + obstacle.height).ceil());
|
|
}
|
|
}
|
|
}
|
|
position.y = y;
|
|
position
|
|
}
|
|
|
|
fn clamp_note_position(
|
|
position: cosmic::iced::Point,
|
|
output: (i32, i32),
|
|
size: (u32, u32),
|
|
) -> cosmic::iced::Point {
|
|
cosmic::iced::Point::new(
|
|
position
|
|
.x
|
|
.clamp(0., ((output.0 as f32 - size.0 as f32).max(0.)).min(8192.)),
|
|
position
|
|
.y
|
|
.clamp(0., ((output.1 as f32 - size.1 as f32).max(0.)).min(8192.)),
|
|
)
|
|
}
|
|
|
|
fn note_origin(note: &ferese_core::desktop::StickyNote, output: (i32, i32)) -> cosmic::iced::Point {
|
|
widget_origin(
|
|
note.anchor,
|
|
note.margin_x,
|
|
note.margin_y,
|
|
(note.width, note.height),
|
|
output,
|
|
)
|
|
}
|
|
|
|
fn widget_origin(
|
|
anchor: ferese_core::desktop::Anchor,
|
|
margin_x: i32,
|
|
margin_y: i32,
|
|
size: (u32, u32),
|
|
output: (i32, i32),
|
|
) -> cosmic::iced::Point {
|
|
use ferese_core::desktop::Anchor as A;
|
|
let x = match anchor {
|
|
A::TopLeft | A::CenterLeft | A::BottomLeft => margin_x as f32,
|
|
A::TopRight | A::CenterRight | A::BottomRight => {
|
|
output.0 as f32 - size.0 as f32 - margin_x as f32
|
|
}
|
|
_ => (output.0 as f32 - size.0 as f32) / 2.,
|
|
};
|
|
let y = match anchor {
|
|
A::TopLeft | A::TopCenter | A::TopRight => margin_y as f32,
|
|
A::BottomLeft | A::BottomCenter | A::BottomRight => {
|
|
output.1 as f32 - size.1 as f32 - margin_y as f32
|
|
}
|
|
_ => (output.1 as f32 - size.1 as f32) / 2.,
|
|
};
|
|
clamp_note_position(cosmic::iced::Point::new(x, y), output, size)
|
|
}
|
|
|
|
fn clock_placement(clock: &ferese_core::desktop::Clock) -> (Anchor, IcedMargin) {
|
|
widget_placement(clock.anchor, clock.margin_x, clock.margin_y)
|
|
}
|
|
|
|
fn widget_placement(
|
|
position: ferese_core::desktop::Anchor,
|
|
margin_x: i32,
|
|
margin_y: i32,
|
|
) -> (Anchor, IcedMargin) {
|
|
use ferese_core::desktop::Anchor as Position;
|
|
let horizontal = match position {
|
|
Position::TopLeft | Position::CenterLeft | Position::BottomLeft => Anchor::LEFT,
|
|
Position::TopRight | Position::CenterRight | Position::BottomRight => Anchor::RIGHT,
|
|
_ => Anchor::empty(),
|
|
};
|
|
let vertical = match position {
|
|
Position::TopLeft | Position::TopCenter | Position::TopRight => Anchor::TOP,
|
|
Position::BottomLeft | Position::BottomCenter | Position::BottomRight => Anchor::BOTTOM,
|
|
_ => Anchor::empty(),
|
|
};
|
|
(
|
|
horizontal | vertical,
|
|
IcedMargin {
|
|
top: if vertical == Anchor::TOP { margin_y } else { 0 },
|
|
bottom: if vertical == Anchor::BOTTOM {
|
|
margin_y
|
|
} else {
|
|
0
|
|
},
|
|
left: if horizontal == Anchor::LEFT {
|
|
margin_x
|
|
} else {
|
|
0
|
|
},
|
|
right: if horizontal == Anchor::RIGHT {
|
|
margin_x
|
|
} else {
|
|
0
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
fn current_time() -> String {
|
|
format_bar_time(&Zoned::now())
|
|
}
|
|
|
|
fn format_bar_time(now: &Zoned) -> String {
|
|
let months = [
|
|
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec",
|
|
];
|
|
let hour = now.hour();
|
|
let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
|
|
format!(
|
|
"{} {}, {}:{:02} {}",
|
|
now.day(),
|
|
months[(now.month() - 1) as usize],
|
|
hour12,
|
|
now.minute(),
|
|
if hour < 12 { "am" } else { "pm" }
|
|
)
|
|
}
|
|
|
|
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
|
|
cosmic::iced::theme::Style {
|
|
background_color: Color::TRANSPARENT,
|
|
text_color: color(theme.text_primary),
|
|
icon_color: color(theme.text_primary),
|
|
}
|
|
}
|
|
|
|
fn bar_style(theme: ShellTheme, compositor_material: bool) -> container::Style {
|
|
container::Style {
|
|
icon_color: Some(color(theme.text_primary)),
|
|
text_color: Some(color(theme.text_primary)),
|
|
// The compositor paints the selected panel material behind this surface.
|
|
// A client-side fill would cover its blur and tint; use it only as fallback.
|
|
background: if compositor_material {
|
|
None
|
|
} else {
|
|
Some(Background::Color(color(theme.bar_background)))
|
|
},
|
|
border: Border {
|
|
radius: theme.bar_radius.into(),
|
|
..Border::default()
|
|
},
|
|
snap: true,
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
fn bar_group_style(theme: ShellTheme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(color_with_opacity(
|
|
theme.text_primary,
|
|
0.035,
|
|
))),
|
|
border: Border {
|
|
color: color_with_opacity(theme.text_muted, 0.12),
|
|
width: 1.0,
|
|
radius: 7.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn color([red, green, blue, alpha]: [u8; 4]) -> Color {
|
|
Color::from_rgba8(red, green, blue, f32::from(alpha) / 255.0)
|
|
}
|
|
|
|
fn color_with_opacity(mut value: [u8; 4], opacity: f32) -> Color {
|
|
value[3] = (f32::from(value[3]) * opacity).round() as u8;
|
|
color(value)
|
|
}
|
|
|
|
fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style {
|
|
let [red, green, blue] = config::default_background();
|
|
|
|
container::Style {
|
|
background: Some(Background::Color(Color::from_rgb8(red, green, blue))),
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
// Resolve a surface while iced guarantees the native handles are alive. Keep
|
|
// the borrowed-display connection alive until the effects binding takes over.
|
|
fn native_wayland_surface(
|
|
window: &dyn cosmic::iced::window::Window,
|
|
) -> Result<(Connection, wl_surface::WlSurface), String> {
|
|
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
|
let display = window.display_handle().map_err(|error| error.to_string())?;
|
|
let handle = window.window_handle().map_err(|error| error.to_string())?;
|
|
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) =
|
|
(display.as_raw(), handle.as_raw())
|
|
else {
|
|
return Err("shell material requires a Wayland surface".to_owned());
|
|
};
|
|
// SAFETY: iced lends matching, live Wayland handles for this callback.
|
|
// This backend borrows the display; it never disconnects iced's connection.
|
|
// Both handles belong to the runtime, which outlives the shell bindings.
|
|
let backend = unsafe {
|
|
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
|
|
};
|
|
let connection = Connection::from_backend(backend);
|
|
// SAFETY: raw-window-handle identifies this pointer as a live wl_surface.
|
|
let id = unsafe {
|
|
wayland_client::backend::ObjectId::from_ptr(
|
|
wl_surface::WlSurface::interface(),
|
|
handle.surface.as_ptr().cast(),
|
|
)
|
|
}
|
|
.map_err(|error| error.to_string())?;
|
|
let surface =
|
|
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
|
|
Ok((connection, surface))
|
|
}
|
|
|
|
struct EffectsBinding {
|
|
connection: Connection,
|
|
_manager: FereseEffectsManagerV1,
|
|
surface: FereseSurfaceEffectsV1,
|
|
_queue: EventQueue<EffectsState>,
|
|
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
|
|
opacity: std::cell::Cell<Option<u32>>,
|
|
}
|
|
|
|
impl EffectsBinding {
|
|
fn attach(
|
|
surface: &wl_surface::WlSurface,
|
|
visible: bool,
|
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
|
Self::attach_role(
|
|
surface,
|
|
visible.then_some(ferese_surface_effects_v1::Role::Panel),
|
|
if visible { 1.0 } else { 0.0 },
|
|
)
|
|
}
|
|
|
|
fn attach_role(
|
|
surface: &wl_surface::WlSurface,
|
|
role: Option<ferese_surface_effects_v1::Role>,
|
|
opacity: f32,
|
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let backend = surface
|
|
.backend()
|
|
.upgrade()
|
|
.ok_or("libcosmic Wayland connection is no longer alive")?;
|
|
let connection = Connection::from_backend(backend);
|
|
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
|
let qh = queue.handle();
|
|
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=3, ())?;
|
|
let effects = manager.get_surface_effects(surface, &qh, ());
|
|
|
|
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
|
if effects.version() >= 3 {
|
|
effects.set_opacity(opacity);
|
|
}
|
|
if let Some(role) = role {
|
|
effects.set_role(role);
|
|
} else {
|
|
effects.clear_role();
|
|
}
|
|
connection.flush()?;
|
|
|
|
Ok(Self {
|
|
connection,
|
|
_manager: manager,
|
|
surface: effects,
|
|
_queue: queue,
|
|
regions: Default::default(),
|
|
opacity: std::cell::Cell::new(Some(opacity)),
|
|
})
|
|
}
|
|
|
|
fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
|
|
if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) {
|
|
return Ok(());
|
|
}
|
|
self.surface.set_regions(
|
|
regions
|
|
.iter()
|
|
.flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes()))
|
|
.collect(),
|
|
);
|
|
if regions.is_empty() {
|
|
self.surface.clear_role();
|
|
} else {
|
|
self.surface
|
|
.set_role(ferese_surface_effects_v1::Role::Popover);
|
|
}
|
|
self.connection.flush()?;
|
|
*self.regions.borrow_mut() = Some(regions.to_vec());
|
|
Ok(())
|
|
}
|
|
|
|
fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
|
|
self.set_opacity(1.0)?;
|
|
if visible {
|
|
self.surface
|
|
.set_role(ferese_surface_effects_v1::Role::Panel);
|
|
} else {
|
|
self.surface.clear_role();
|
|
}
|
|
|
|
self.connection.flush()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> {
|
|
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
|
if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) {
|
|
self.surface.set_opacity(opacity);
|
|
self.connection.flush()?;
|
|
self.opacity.set(Some(opacity));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct EffectsState;
|
|
|
|
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
|
|
fn event(
|
|
_state: &mut Self,
|
|
_registry: &wl_registry::WlRegistry,
|
|
_event: wl_registry::Event,
|
|
_data: &GlobalListContents,
|
|
_connection: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
delegate_noop!(EffectsState: ignore FereseEffectsManagerV1);
|
|
delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn widget_drag_stops_at_edges_without_tunnelling_and_slides() {
|
|
use cosmic::iced::{Point, Rectangle};
|
|
let obstacles = [Rectangle {
|
|
x: 100.,
|
|
y: 0.,
|
|
width: 40.,
|
|
height: 100.,
|
|
}];
|
|
assert_eq!(
|
|
super::avoid_widget_overlap(
|
|
Point::new(0., 0.),
|
|
Point::new(300., 0.),
|
|
(20, 20),
|
|
&obstacles
|
|
),
|
|
Point::new(80., 0.)
|
|
);
|
|
assert_eq!(
|
|
super::avoid_widget_overlap(
|
|
Point::new(80., 0.),
|
|
Point::new(120., 50.),
|
|
(20, 20),
|
|
&obstacles
|
|
),
|
|
Point::new(80., 50.)
|
|
);
|
|
assert_eq!(
|
|
super::avoid_widget_overlap(
|
|
Point::new(160., 0.),
|
|
Point::new(0., 0.),
|
|
(20, 20),
|
|
&obstacles
|
|
),
|
|
Point::new(140., 0.)
|
|
);
|
|
assert_eq!(
|
|
super::avoid_widget_overlap(
|
|
Point::new(100., 120.),
|
|
Point::new(100., 0.),
|
|
(20, 20),
|
|
&obstacles
|
|
),
|
|
Point::new(100., 100.)
|
|
);
|
|
assert_eq!(
|
|
super::avoid_widget_overlap(
|
|
Point::new(0., 100.),
|
|
Point::new(200., 100.),
|
|
(20, 20),
|
|
&obstacles
|
|
),
|
|
Point::new(200., 100.)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pixel_clock_stacks_only_hour_and_minute_formats() {
|
|
assert_eq!(
|
|
super::stacked_clock_digits("2:45 pm"),
|
|
Some(["02".into(), "45".into()])
|
|
);
|
|
assert_eq!(
|
|
super::stacked_clock_digits("14:45"),
|
|
Some(["14".into(), "45".into()])
|
|
);
|
|
assert!(super::stacked_clock_digits("14:45:30").is_none());
|
|
assert!(super::stacked_clock_digits("Today 14:45").is_none());
|
|
assert!(super::stacked_clock_digits("14:45 pm extra").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn note_drag_origin_and_bounds_use_logical_output_coordinates() {
|
|
use cosmic::iced::Point;
|
|
let mut note = ferese_core::desktop::StickyNote::default();
|
|
assert_eq!(
|
|
super::note_origin(¬e, (1920, 1080)),
|
|
Point::new(1552., 80.)
|
|
);
|
|
note.anchor = ferese_core::desktop::Anchor::Center;
|
|
assert_eq!(
|
|
super::note_origin(¬e, (1920, 1080)),
|
|
Point::new(800., 420.)
|
|
);
|
|
assert_eq!(
|
|
super::clamp_note_position(Point::new(-20., 1200.), (1920, 1080), (320, 240)),
|
|
Point::new(0., 840.)
|
|
);
|
|
assert_eq!(
|
|
super::clamp_note_position(Point::new(20., 30.), (200, 100), (320, 240)),
|
|
Point::ORIGIN
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_clock_anchor_margins_match_only_anchored_edges() {
|
|
use ferese_core::desktop::{Anchor as Position, Clock};
|
|
let clock = Clock {
|
|
anchor: Position::BottomRight,
|
|
margin_x: 30,
|
|
margin_y: 40,
|
|
..Clock::default()
|
|
};
|
|
let (anchor, margin) = super::clock_placement(&clock);
|
|
assert_eq!(anchor, super::Anchor::BOTTOM | super::Anchor::RIGHT);
|
|
assert_eq!(
|
|
(margin.top, margin.right, margin.bottom, margin.left),
|
|
(0, 30, 40, 0)
|
|
);
|
|
let (anchor, margin) = super::clock_placement(&Clock {
|
|
anchor: Position::Center,
|
|
..clock
|
|
});
|
|
assert!(anchor.is_empty());
|
|
assert_eq!(
|
|
(margin.top, margin.right, margin.bottom, margin.left),
|
|
(0, 0, 0, 0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clock_uses_lowercase_date_and_twelve_hour_time() {
|
|
for (stamp, expected) in [
|
|
(
|
|
"2026-09-28T21:32:00+01:00[Africa/Lagos]",
|
|
"28 sept, 9:32 pm",
|
|
),
|
|
(
|
|
"2026-09-28T00:05:00+01:00[Africa/Lagos]",
|
|
"28 sept, 12:05 am",
|
|
),
|
|
(
|
|
"2026-09-28T12:00:00+01:00[Africa/Lagos]",
|
|
"28 sept, 12:00 pm",
|
|
),
|
|
] {
|
|
assert_eq!(super::format_bar_time(&stamp.parse().unwrap()), expected);
|
|
}
|
|
}
|
|
use super::*;
|
|
use crate::control::{OutputSnapshot, WindowSnapshot};
|
|
|
|
#[test]
|
|
fn shell_font_respects_configured_family() {
|
|
assert_eq!(
|
|
configured_font(Some("JetBrainsMono Nerd Font")),
|
|
cosmic::font::Font::with_name("JetBrainsMono Nerd Font")
|
|
);
|
|
assert_eq!(configured_font(None), cosmic::font::default());
|
|
}
|
|
|
|
#[test]
|
|
fn icons_use_theme_accent_and_preserve_battery_status_colors() {
|
|
for (source, accent) in [
|
|
(
|
|
include_bytes!("../assets/icons/status/battery-full.svg").as_slice(),
|
|
"#63c168",
|
|
),
|
|
(
|
|
include_bytes!("../assets/icons/status/battery-50.svg").as_slice(),
|
|
"#3d7be6",
|
|
),
|
|
(
|
|
include_bytes!("../assets/icons/status/battery-25.svg").as_slice(),
|
|
"#e0654f",
|
|
),
|
|
(
|
|
include_bytes!("../assets/icons/status/wifi-full.svg").as_slice(),
|
|
"#3d7be6",
|
|
),
|
|
(
|
|
include_bytes!("../assets/icons/status/control-center.svg").as_slice(),
|
|
"#3d7be6",
|
|
),
|
|
] {
|
|
let svg = String::from_utf8(tinted_svg(
|
|
source,
|
|
Color::from_rgb8(205, 214, 244),
|
|
Color::from_rgb8(203, 166, 247),
|
|
))
|
|
.unwrap();
|
|
assert!(!svg.contains("currentColor"));
|
|
assert!(svg.contains("rgba(205,214,244,1)"));
|
|
assert!(!svg.contains("#3d7be6"));
|
|
assert!(svg.contains(if accent == "#3d7be6" {
|
|
"rgba(203,166,247,1)"
|
|
} else {
|
|
accent
|
|
}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_selector_has_distinct_active_paint() {
|
|
let theme = ShellTheme::default();
|
|
let active = workspace_selector_style(true, false, false, theme);
|
|
let occupied = workspace_selector_style(false, false, true, theme);
|
|
let inactive = workspace_selector_style(false, false, false, theme);
|
|
let remote = workspace_selector_style(false, true, false, theme);
|
|
assert!(active.background.is_some());
|
|
assert_eq!(
|
|
active.background,
|
|
Some(Background::Color(color_with_opacity(theme.accent, 0.16)))
|
|
);
|
|
assert!(occupied.background.is_some());
|
|
assert!(inactive.background.is_none());
|
|
assert_eq!(inactive.border.width, 0.0);
|
|
assert_eq!(remote.border.width, 1.0);
|
|
assert!(remote.background.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_highlight_tracks_the_bars_output() {
|
|
let output = control::OutputSnapshot {
|
|
id: 1,
|
|
name: "eDP-1".into(),
|
|
active_workspace: 2,
|
|
focused: false,
|
|
};
|
|
assert!(workspace_active_on_bar(2, Some(&output)));
|
|
assert!(!workspace_active_on_bar(1, Some(&output)));
|
|
assert!(!workspace_active_on_bar(2, None));
|
|
}
|
|
|
|
#[test]
|
|
fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() {
|
|
let theme = ShellTheme::default();
|
|
assert_eq!(
|
|
shell_surface_style(theme).background_color,
|
|
Color::TRANSPARENT
|
|
);
|
|
let Some(Background::Color(background)) = bar_style(theme.for_bar(), false).background
|
|
else {
|
|
panic!("first frame needs a fallback fill before material attachment");
|
|
};
|
|
assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20);
|
|
assert!(background.a > 0.9);
|
|
assert!(
|
|
theme.bar_text_primary[..3]
|
|
.iter()
|
|
.all(|channel| *channel > 220)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bar_fallback_uses_the_matching_dark_palette_and_geometry() {
|
|
let theme = ShellTheme::default().for_bar();
|
|
let style = bar_style(theme, false);
|
|
assert_eq!(
|
|
style.background,
|
|
Some(Background::Color(color(theme.bar_background)))
|
|
);
|
|
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
|
|
assert_eq!(style.border.radius, theme.bar_radius.into());
|
|
assert_eq!(style.shadow, cosmic::iced::Shadow::default());
|
|
}
|
|
|
|
#[test]
|
|
fn bar_does_not_cover_the_selected_compositor_material() {
|
|
let theme = ShellTheme::default().for_bar();
|
|
let style = bar_style(theme, true);
|
|
assert_eq!(style.background, None);
|
|
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
|
|
assert_eq!(style.border.radius, theme.bar_radius.into());
|
|
assert_eq!(style.shadow, cosmic::iced::Shadow::default());
|
|
}
|
|
|
|
#[test]
|
|
fn compact_bar_preserves_logical_text_and_icon_sizes() {
|
|
for height in [24.0, 26.0, 32.0, 38.0, 44.0] {
|
|
let metrics = BarMetrics::from(ShellTheme {
|
|
bar_height: height,
|
|
..ShellTheme::default()
|
|
});
|
|
assert_eq!(metrics.text_size, 14);
|
|
assert_eq!(metrics.icon_size, 20);
|
|
assert_eq!(metrics.overview_icon_size, 20);
|
|
assert!(metrics.control_height >= f32::from(metrics.icon_size));
|
|
assert!(metrics.control_height <= height);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fullscreen_on_the_active_workspace_hides_the_bar() {
|
|
let snapshot = snapshot_with_fullscreen_window(7);
|
|
|
|
assert!(bar_hidden(&snapshot));
|
|
}
|
|
|
|
#[test]
|
|
fn fullscreen_on_an_inactive_workspace_keeps_the_bar_visible() {
|
|
let snapshot = snapshot_with_fullscreen_window(8);
|
|
|
|
assert!(!bar_hidden(&snapshot));
|
|
}
|
|
|
|
#[test]
|
|
fn fullscreen_visibility_is_local_to_each_monitor() {
|
|
let mut snapshot = snapshot_with_fullscreen_window(7);
|
|
snapshot.outputs.push(OutputSnapshot {
|
|
id: 2,
|
|
name: "external-test".to_owned(),
|
|
active_workspace: 9,
|
|
focused: false,
|
|
});
|
|
assert!(output_bar_hidden(&snapshot, Some(1)));
|
|
assert!(!output_bar_hidden(&snapshot, Some(2)));
|
|
assert!(!output_bar_hidden(&snapshot, None));
|
|
snapshot.outputs[0].focused = false;
|
|
snapshot.outputs[1].focused = true;
|
|
assert!(output_bar_hidden(&snapshot, Some(1)));
|
|
assert!(!output_bar_hidden(&snapshot, Some(2)));
|
|
}
|
|
|
|
fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot {
|
|
ShellSnapshot {
|
|
outputs: vec![OutputSnapshot {
|
|
id: 1,
|
|
name: "eDP-1".to_owned(),
|
|
active_workspace: 7,
|
|
focused: true,
|
|
}],
|
|
workspaces: Vec::new(),
|
|
windows: vec![WindowSnapshot {
|
|
id: 1,
|
|
workspace,
|
|
app_id: "dev.ferese.Test".to_owned(),
|
|
title: "Test".to_owned(),
|
|
focused: true,
|
|
fullscreen: true,
|
|
}],
|
|
}
|
|
}
|
|
}
|