Skip to main content

cosmic/applet/
mod.rs

1#[cfg(feature = "applet-token")]
2pub mod token;
3
4use crate::app::{BootData, BootDataInner, cosmic, iced_settings};
5use crate::cctk::sctk;
6use crate::theme::{self, Button, THEME, system_dark, system_light};
7use crate::widget::autosize::{self, Autosize, autosize};
8use crate::widget::column::Column;
9use crate::widget::row::Row;
10use crate::widget::space::{horizontal, vertical};
11use crate::widget::{self, layer_container};
12use crate::{Application, Element, Renderer};
13
14pub use cosmic_panel_config;
15use cosmic_panel_config::{CosmicPanelBackground, PanelAnchor, PanelSize};
16use iced::alignment::{Alignment, Horizontal, Vertical};
17use iced::widget::Container;
18use iced::{self, Color, Length, Limits, Rectangle, window};
19use iced_core::{Padding, Shadow};
20use iced_runtime::platform_specific::wayland::popup::{SctkPopupSettings, SctkPositioner};
21use iced_widget::Text;
22use sctk::reexports::protocols::xdg::shell::client::xdg_positioner::{Anchor, Gravity};
23use std::borrow::Cow;
24use std::cell::RefCell;
25use std::num::NonZeroU32;
26use std::rc::Rc;
27use std::sync::LazyLock;
28use std::time::Duration;
29use tracing::info;
30
31pub mod column;
32pub mod row;
33
34static AUTOSIZE_ID: LazyLock<iced::id::Id> =
35    LazyLock::new(|| iced::id::Id::new("cosmic-applet-autosize"));
36static AUTOSIZE_MAIN_ID: LazyLock<iced::id::Id> =
37    LazyLock::new(|| iced::id::Id::new("cosmic-applet-autosize-main"));
38static TOOLTIP_ID: LazyLock<crate::widget::Id> = LazyLock::new(|| iced::id::Id::new("subsurface"));
39pub(crate) static TOOLTIP_WINDOW_ID: LazyLock<window::Id> = LazyLock::new(window::Id::unique);
40
41#[derive(Debug, Clone)]
42pub struct Context {
43    pub size: Size,
44    pub anchor: PanelAnchor,
45    pub spacing: u32,
46    pub background: CosmicPanelBackground,
47    pub output_name: String,
48    pub panel_type: PanelType,
49    /// Includes the configured size of the window.
50    /// This can be used by apples to handle overflow themselves.
51    pub suggested_bounds: Option<iced::Size>,
52    /// Ratio of overlap for applet padding.
53    pub padding_overlap: f32,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum Size {
58    // (width, height)
59    Hardcoded((u16, u16)),
60    PanelSize(PanelSize),
61}
62#[derive(Clone, Debug, PartialEq)]
63pub enum PanelType {
64    Panel,
65    Dock,
66    Other(String),
67}
68
69impl ToString for PanelType {
70    fn to_string(&self) -> String {
71        match self {
72            Self::Panel => "Panel".to_string(),
73            Self::Dock => "Dock".to_string(),
74            Self::Other(other) => other.clone(),
75        }
76    }
77}
78
79impl From<String> for PanelType {
80    fn from(value: String) -> Self {
81        match value.as_str() {
82            "Panel" => PanelType::Panel,
83            "Dock" => PanelType::Dock,
84            _ => PanelType::Other(value),
85        }
86    }
87}
88
89impl Default for Context {
90    fn default() -> Self {
91        Self {
92            size: Size::PanelSize(
93                std::env::var("COSMIC_PANEL_SIZE")
94                    .ok()
95                    .and_then(|size| ron::from_str(size.as_str()).ok())
96                    .unwrap_or(PanelSize::S),
97            ),
98            anchor: std::env::var("COSMIC_PANEL_ANCHOR")
99                .ok()
100                .and_then(|size| ron::from_str(size.as_str()).ok())
101                .unwrap_or(PanelAnchor::Top),
102            spacing: std::env::var("COSMIC_PANEL_SPACING")
103                .ok()
104                .and_then(|size| ron::from_str(size.as_str()).ok())
105                .unwrap_or(4),
106            background: std::env::var("COSMIC_PANEL_BACKGROUND")
107                .ok()
108                .and_then(|size| ron::from_str(size.as_str()).ok())
109                .unwrap_or(CosmicPanelBackground::ThemeDefault),
110            output_name: std::env::var("COSMIC_PANEL_OUTPUT").unwrap_or_default(),
111            panel_type: PanelType::from(std::env::var("COSMIC_PANEL_NAME").unwrap_or_default()),
112            padding_overlap: str::parse(
113                &std::env::var("COSMIC_PANEL_PADDING_OVERLAP").unwrap_or_default(),
114            )
115            .unwrap_or(0.0),
116            suggested_bounds: None,
117        }
118    }
119}
120
121impl Context {
122    #[must_use]
123    pub fn suggested_size(&self, is_symbolic: bool) -> (u16, u16) {
124        match &self.size {
125            Size::PanelSize(size) => {
126                let s = size.get_applet_icon_size(is_symbolic) as u16;
127                (s, s)
128            }
129            Size::Hardcoded((width, height)) => (*width, *height),
130        }
131    }
132
133    #[must_use]
134    pub fn suggested_window_size(&self) -> (NonZeroU32, NonZeroU32) {
135        let suggested = self.suggested_size(true);
136        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
137        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
138            (applet_padding_major_axis, applet_padding_minor_axis)
139        } else {
140            (applet_padding_minor_axis, applet_padding_major_axis)
141        };
142
143        let configured_width = self
144            .suggested_bounds
145            .as_ref()
146            .and_then(|c| NonZeroU32::new(c.width as u32)) // TODO: should this be physical size instead of logical?
147            .unwrap_or_else(|| {
148                NonZeroU32::new(suggested.0 as u32 + horizontal_padding as u32 * 2).unwrap()
149            });
150
151        let configured_height = self
152            .suggested_bounds
153            .as_ref()
154            .and_then(|c| NonZeroU32::new(c.height as u32))
155            .unwrap_or_else(|| {
156                NonZeroU32::new(suggested.1 as u32 + vertical_padding as u32 * 2).unwrap()
157            });
158        info!("{configured_height:?}");
159        (configured_width, configured_height)
160    }
161
162    #[must_use]
163    pub fn suggested_padding(&self, is_symbolic: bool) -> (u16, u16) {
164        match &self.size {
165            Size::PanelSize(size) => (
166                size.get_applet_shrinkable_padding(is_symbolic),
167                size.get_applet_padding(is_symbolic),
168            ),
169            Size::Hardcoded(_) => (12, 8),
170        }
171    }
172
173    // Set the default window size. Helper for application init with hardcoded size.
174    pub fn window_size(&mut self, width: u16, height: u16) {
175        self.size = Size::Hardcoded((width, height));
176    }
177
178    #[allow(clippy::cast_precision_loss)]
179    pub fn window_settings(&self) -> crate::app::Settings {
180        let (width, height) = self.suggested_size(true);
181        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
182        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
183            (applet_padding_major_axis, applet_padding_minor_axis)
184        } else {
185            (applet_padding_minor_axis, applet_padding_major_axis)
186        };
187
188        let width = f32::from(width) + horizontal_padding as f32 * 2.;
189        let height = f32::from(height) + vertical_padding as f32 * 2.;
190        let mut settings = crate::app::Settings::default()
191            .size(iced_core::Size::new(width, height))
192            .size_limits(Limits::NONE.min_height(height).min_width(width))
193            .resizable(None)
194            .default_text_size(14.0)
195            .transparent(true);
196        if let Some(theme) = self.theme() {
197            settings = settings.theme(theme);
198        }
199        settings.exit_on_close = true;
200        settings
201    }
202
203    #[must_use]
204    pub fn is_horizontal(&self) -> bool {
205        matches!(self.anchor, PanelAnchor::Top | PanelAnchor::Bottom)
206    }
207
208    pub fn icon_button_from_handle<'a, Message: Clone + 'static>(
209        &self,
210        icon: widget::icon::Handle,
211    ) -> crate::widget::Button<'a, Message> {
212        let suggested = self.suggested_size(icon.symbolic);
213        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
214        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
215            (applet_padding_major_axis, applet_padding_minor_axis)
216        } else {
217            (applet_padding_minor_axis, applet_padding_major_axis)
218        };
219        let symbolic = icon.symbolic;
220        let icon = widget::icon(icon)
221            .class(if symbolic {
222                theme::Svg::Custom(Rc::new(|theme| iced_widget::svg::Style {
223                    color: Some(theme.cosmic().background(theme.transparent).on.into()),
224                }))
225            } else {
226                theme::Svg::default()
227            })
228            .width(Length::Fixed(suggested.0 as f32))
229            .height(Length::Fixed(suggested.1 as f32));
230        self.button_from_element(icon, symbolic)
231    }
232
233    pub fn button_from_element<'a, Message: Clone + 'static>(
234        &self,
235        content: impl Into<Element<'a, Message>>,
236        use_symbolic_size: bool,
237    ) -> crate::widget::Button<'a, Message> {
238        let suggested = self.suggested_size(use_symbolic_size);
239        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
240        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
241            (applet_padding_major_axis, applet_padding_minor_axis)
242        } else {
243            (applet_padding_minor_axis, applet_padding_major_axis)
244        };
245
246        crate::widget::button::custom(layer_container(content).center(Length::Fill))
247            .width(Length::Fixed((suggested.0 + 2 * horizontal_padding) as f32))
248            .height(Length::Fixed((suggested.1 + 2 * vertical_padding) as f32))
249            .class(Button::AppletIcon)
250    }
251
252    pub fn text_button<'a, Message: Clone + 'static>(
253        &self,
254        text: impl Into<Text<'a, crate::Theme, crate::Renderer>>,
255        message: Message,
256    ) -> crate::widget::Button<'a, Message> {
257        let text = text.into();
258        let suggested = self.suggested_size(true);
259
260        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
261        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
262            (applet_padding_major_axis, applet_padding_minor_axis)
263        } else {
264            (applet_padding_minor_axis, applet_padding_major_axis)
265        };
266        crate::widget::button::custom(
267            layer_container(
268                Text::from(text)
269                    .height(Length::Fill)
270                    .align_y(Alignment::Center),
271            )
272            .center_y(Length::Fixed(f32::from(suggested.1 + 2 * vertical_padding))),
273        )
274        .on_press_down(message)
275        .padding([0, horizontal_padding])
276        .class(crate::theme::Button::AppletIcon)
277    }
278
279    pub fn icon_button<'a, Message: Clone + 'static>(
280        &self,
281        icon_name: &'a str,
282    ) -> crate::widget::Button<'a, Message> {
283        let suggested_size = self.suggested_size(true);
284        self.icon_button_from_handle(
285            widget::icon::from_name(icon_name)
286                .symbolic(true)
287                .size(suggested_size.0)
288                .into(),
289        )
290    }
291
292    pub fn applet_tooltip<'a, Message: 'static>(
293        &self,
294        content: impl Into<Element<'a, Message>>,
295        tooltip: impl Into<Cow<'static, str>>,
296        has_popup: bool,
297        on_surface_action: impl Fn(crate::surface::Action<Message>) -> Message + 'static,
298        parent_id: Option<window::Id>,
299    ) -> crate::widget::wayland::tooltip::widget::Tooltip<'a, Message, Message> {
300        let window_id = *TOOLTIP_WINDOW_ID;
301        let subsurface_id = TOOLTIP_ID.clone();
302        let anchor = self.anchor;
303        let tooltip = tooltip.into();
304
305        crate::widget::wayland::tooltip::widget::Tooltip::<'a, Message, Message>::new(
306            content,
307            (!has_popup).then_some(move |bounds: Rectangle| {
308                let window_id = window_id;
309                let (popup_anchor, gravity) = match anchor {
310                    PanelAnchor::Left => (Anchor::Right, Gravity::Right),
311                    PanelAnchor::Right => (Anchor::Left, Gravity::Left),
312                    PanelAnchor::Top => (Anchor::Bottom, Gravity::Bottom),
313                    PanelAnchor::Bottom => (Anchor::Top, Gravity::Top),
314                };
315
316                SctkPopupSettings {
317                    parent: parent_id.unwrap_or(window::Id::RESERVED),
318                    id: window_id,
319                    grab: false,
320                    input_zone: Some(vec![Rectangle::new(
321                        iced::Point::new(-1000., -1000.),
322                        iced::Size::default(),
323                    )]),
324                    positioner: SctkPositioner {
325                        size: None,
326                        size_limits: Limits::NONE.min_width(1.).min_height(1.),
327                        anchor_rect: Rectangle {
328                            x: bounds.x.round() as i32,
329                            y: bounds.y.round() as i32,
330                            width: bounds.width.round() as i32,
331                            height: bounds.height.round() as i32,
332                        },
333                        anchor: popup_anchor,
334                        gravity,
335                        constraint_adjustment: 15,
336                        offset: (0, 0),
337                        reactive: true,
338                    },
339                    parent_size: None,
340                    close_with_children: true,
341                }
342            }),
343            move || {
344                Element::from(autosize::autosize(
345                    layer_container(crate::widget::text(tooltip.clone()))
346                        .layer(crate::cosmic_theme::Layer::Background)
347                        .padding(4.),
348                    subsurface_id.clone(),
349                ))
350            },
351            on_surface_action(crate::surface::Action::DestroyPopup(window_id)),
352            on_surface_action,
353        )
354        .delay(Duration::from_millis(100))
355    }
356
357    // TODO popup container which tracks the size of itself and requests the popup to resize to match
358    pub fn popup_container<'a, Message: 'static>(
359        &self,
360        content: impl Into<Element<'a, Message>>,
361    ) -> Autosize<'a, Message, crate::Theme, Renderer> {
362        let (vertical_align, horizontal_align) = match self.anchor {
363            PanelAnchor::Left => (Vertical::Center, Horizontal::Left),
364            PanelAnchor::Right => (Vertical::Center, Horizontal::Right),
365            PanelAnchor::Top => (Vertical::Top, Horizontal::Center),
366            PanelAnchor::Bottom => (Vertical::Bottom, Horizontal::Center),
367        };
368
369        autosize(
370            Container::<Message, _, Renderer>::new(
371                Container::<Message, _, Renderer>::new(content).style(|theme| {
372                    let cosmic = theme.cosmic();
373                    let corners = cosmic.corner_radii;
374                    let mut bg = cosmic.background(theme.transparent).base;
375                    iced_widget::container::Style {
376                        text_color: Some(cosmic.background(theme.transparent).on.into()),
377                        background: Some(Color::from(bg).into()),
378                        border: iced::Border {
379                            radius: corners.radius_m.into(),
380                            width: 1.0,
381                            color: cosmic.background(theme.transparent).divider.into(),
382                        },
383                        shadow: Shadow::default(),
384                        icon_color: Some(cosmic.background(theme.transparent).on.into()),
385                        snap: true,
386                    }
387                }),
388            )
389            .height(Length::Shrink)
390            .align_x(horizontal_align)
391            .align_y(vertical_align),
392            AUTOSIZE_ID.clone(),
393        )
394        .limits(
395            Limits::NONE
396                .min_height(1.)
397                .min_width(360.0)
398                .max_width(360.0)
399                .max_height(1000.0),
400        )
401    }
402
403    #[must_use]
404    #[allow(clippy::cast_possible_wrap)]
405    pub fn get_popup_settings(
406        &self,
407        parent: window::Id,
408        id: window::Id,
409        size: Option<(u32, u32)>,
410        width_padding: Option<i32>,
411        height_padding: Option<i32>,
412    ) -> SctkPopupSettings {
413        let (width, height) = self.suggested_size(true);
414        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
415        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
416            (applet_padding_major_axis, applet_padding_minor_axis)
417        } else {
418            (applet_padding_minor_axis, applet_padding_major_axis)
419        };
420        let pixel_offset = 4;
421        let (offset, anchor, gravity) = match self.anchor {
422            PanelAnchor::Left => ((pixel_offset, 0), Anchor::Right, Gravity::Right),
423            PanelAnchor::Right => ((-pixel_offset, 0), Anchor::Left, Gravity::Left),
424            PanelAnchor::Top => ((0, pixel_offset), Anchor::Bottom, Gravity::Bottom),
425            PanelAnchor::Bottom => ((0, -pixel_offset), Anchor::Top, Gravity::Top),
426        };
427        SctkPopupSettings {
428            parent,
429            id,
430            positioner: SctkPositioner {
431                anchor,
432                gravity,
433                offset,
434                size,
435                anchor_rect: Rectangle {
436                    x: 0,
437                    y: 0,
438                    width: width_padding.unwrap_or(horizontal_padding as i32) * 2
439                        + i32::from(width),
440                    height: height_padding.unwrap_or(vertical_padding as i32) * 2
441                        + i32::from(height),
442                },
443                reactive: true,
444                constraint_adjustment: 15, // slide_y, slide_x, flip_x, flip_y
445                size_limits: Limits::NONE
446                    .min_height(1.0)
447                    .min_width(360.0)
448                    .max_width(360.0)
449                    .max_height(1080.0),
450            },
451            parent_size: None,
452            grab: true,
453            close_with_children: false,
454            input_zone: None,
455        }
456    }
457
458    pub fn autosize_window<'a, Message: 'static>(
459        &self,
460        content: impl Into<Element<'a, Message>>,
461    ) -> Autosize<'a, Message, crate::Theme, crate::Renderer> {
462        let force_configured = matches!(&self.panel_type, PanelType::Other(n) if n.is_empty());
463        let w = autosize(content, AUTOSIZE_MAIN_ID.clone());
464        let mut limits = Limits::NONE;
465        let suggested_window_size = self.suggested_window_size();
466
467        if let Some(width) = self
468            .suggested_bounds
469            .as_ref()
470            .filter(|c| c.width as i32 > 0)
471            .map(|c| c.width)
472        {
473            limits = limits.width(width);
474        }
475        if let Some(height) = self
476            .suggested_bounds
477            .as_ref()
478            .filter(|c| c.height as i32 > 0)
479            .map(|c| c.height)
480        {
481            limits = limits.height(height);
482        }
483
484        w.limits(limits)
485    }
486
487    #[must_use]
488    pub fn theme(&self) -> Option<theme::Theme> {
489        match self.background {
490            CosmicPanelBackground::Dark => {
491                let mut theme = system_dark();
492                theme.theme_type.prefer_dark(Some(true));
493                Some(theme)
494            }
495            CosmicPanelBackground::Light => {
496                let mut theme = system_light();
497                theme.theme_type.prefer_dark(Some(false));
498                Some(theme)
499            }
500            _ => Some(theme::system_preference()),
501        }
502    }
503
504    pub fn text<'a>(&self, msg: impl Into<Cow<'a, str>>) -> crate::widget::Text<'a, crate::Theme> {
505        let msg = msg.into();
506        let t = match self.size {
507            Size::Hardcoded(_) => crate::widget::text,
508            Size::PanelSize(ref s) => {
509                let size = s.get_applet_icon_size_with_padding(false);
510
511                let size_threshold_small = PanelSize::S.get_applet_icon_size_with_padding(false);
512                let size_threshold_medium = PanelSize::M.get_applet_icon_size_with_padding(false);
513                let size_threshold_large = PanelSize::L.get_applet_icon_size_with_padding(false);
514
515                if size <= size_threshold_small {
516                    crate::widget::text::body
517                } else if size <= size_threshold_medium {
518                    crate::widget::text::title4
519                } else if size <= size_threshold_large {
520                    crate::widget::text::title3
521                } else {
522                    crate::widget::text::title2
523                }
524            }
525        };
526        t(msg).font(crate::font::default())
527    }
528}
529
530/// Launch the application with the given settings.
531///
532/// # Errors
533///
534/// Returns error on application failure.
535pub fn run<App: Application>(flags: App::Flags) -> iced::Result {
536    if let Some(fd) = std::env::var("X_PRIVILEGED_WAYLAND_SOCKET")
537        .ok()
538        .and_then(|fd| fd.parse::<std::os::fd::RawFd>().ok())
539    {
540        let fd = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
541        if let Ok(flags) = rustix::io::fcntl_getfd(fd) {
542            let _ = rustix::io::fcntl_setfd(fd, flags | rustix::io::FdFlags::CLOEXEC);
543        }
544    }
545    let helper = Context::default();
546
547    let mut settings = helper.window_settings();
548    settings.resizable = None;
549
550    #[cfg(all(target_env = "gnu", not(target_os = "windows")))]
551    if let Some(threshold) = settings.default_mmap_threshold {
552        crate::malloc::limit_mmap_threshold(threshold);
553    }
554
555    let (iced_settings, (mut core, flags), mut window_settings) =
556        iced_settings::<App>(settings, flags);
557    core.window.show_headerbar = false;
558    core.window.sharp_corners = true;
559    core.window.show_maximize = false;
560    core.window.show_minimize = false;
561    core.window.use_template = false;
562    core.app_type = crate::core::AppType::Applet;
563
564    window_settings.decorations = false;
565    window_settings.exit_on_close_request = true;
566    window_settings.resizable = false;
567    window_settings.resize_border = 0;
568
569    // TODO make multi-window not mandatory
570
571    let no_main_window = core.main_window.is_none();
572    if no_main_window {
573        // TODO still apply window settings?
574        // window_settings = window_settings.clone();
575        core.main_window = Some(iced_core::window::Id::RESERVED);
576    }
577    let mut app = iced::daemon(
578        BootData(Rc::new(RefCell::new(Some(BootDataInner::<App> {
579            flags,
580            core,
581            settings: window_settings,
582        })))),
583        cosmic::Cosmic::update,
584        cosmic::Cosmic::view,
585    );
586
587    app.subscription(cosmic::Cosmic::subscription)
588        .style(cosmic::Cosmic::style)
589        .theme(cosmic::Cosmic::theme)
590        .settings(iced_settings)
591        .run()
592}
593
594#[must_use]
595pub fn style() -> iced::theme::Style {
596    let theme = crate::theme::THEME.lock().unwrap();
597    iced::theme::Style {
598        background_color: Color::from_rgba(0.0, 0.0, 0.0, 0.0),
599        text_color: theme.cosmic().on_bg_color().into(),
600        icon_color: theme.cosmic().on_bg_color().into(),
601    }
602}
603
604pub fn menu_button<'a, Message: Clone + 'a>(
605    content: impl Into<Element<'a, Message>>,
606) -> crate::widget::Button<'a, Message> {
607    crate::widget::button::custom(content)
608        .class(Button::AppletMenu)
609        .padding(menu_control_padding())
610        .width(Length::Fill)
611}
612
613pub fn padded_control<'a, Message>(
614    content: impl Into<Element<'a, Message>>,
615) -> crate::widget::container::Container<'a, Message, crate::Theme, crate::Renderer> {
616    crate::widget::container(content)
617        .padding(menu_control_padding())
618        .width(Length::Fill)
619}
620
621pub fn menu_control_padding() -> Padding {
622    let guard = THEME.lock().unwrap();
623    let cosmic = guard.cosmic();
624    [cosmic.space_xxs(), cosmic.space_m()].into()
625}