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            .default_font(crate::font::default())
196            .transparent(true);
197        if let Some(theme) = self.theme() {
198            settings = settings.theme(theme);
199        }
200        settings.exit_on_close = true;
201        settings
202    }
203
204    #[must_use]
205    pub fn is_horizontal(&self) -> bool {
206        matches!(self.anchor, PanelAnchor::Top | PanelAnchor::Bottom)
207    }
208
209    pub fn icon_button_from_handle<'a, Message: Clone + 'static>(
210        &self,
211        icon: widget::icon::Handle,
212    ) -> crate::widget::Button<'a, Message> {
213        let suggested = self.suggested_size(icon.symbolic);
214        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
215        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
216            (applet_padding_major_axis, applet_padding_minor_axis)
217        } else {
218            (applet_padding_minor_axis, applet_padding_major_axis)
219        };
220        let symbolic = icon.symbolic;
221        let icon = widget::icon(icon)
222            .class(if symbolic {
223                theme::Svg::Custom(Rc::new(|theme| iced_widget::svg::Style {
224                    color: Some(theme.cosmic().background(theme.transparent).on.into()),
225                }))
226            } else {
227                theme::Svg::default()
228            })
229            .width(Length::Fixed(suggested.0 as f32))
230            .height(Length::Fixed(suggested.1 as f32));
231        self.button_from_element(icon, symbolic)
232    }
233
234    pub fn button_from_element<'a, Message: Clone + 'static>(
235        &self,
236        content: impl Into<Element<'a, Message>>,
237        use_symbolic_size: bool,
238    ) -> crate::widget::Button<'a, Message> {
239        let suggested = self.suggested_size(use_symbolic_size);
240        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
241        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
242            (applet_padding_major_axis, applet_padding_minor_axis)
243        } else {
244            (applet_padding_minor_axis, applet_padding_major_axis)
245        };
246
247        crate::widget::button::custom(layer_container(content).center(Length::Fill))
248            .width(Length::Fixed((suggested.0 + 2 * horizontal_padding) as f32))
249            .height(Length::Fixed((suggested.1 + 2 * vertical_padding) as f32))
250            .class(Button::AppletIcon)
251    }
252
253    pub fn text_button<'a, Message: Clone + 'static>(
254        &self,
255        text: impl Into<Text<'a, crate::Theme, crate::Renderer>>,
256        message: Message,
257    ) -> crate::widget::Button<'a, Message> {
258        let text = text.into();
259        let suggested = self.suggested_size(true);
260
261        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
262        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
263            (applet_padding_major_axis, applet_padding_minor_axis)
264        } else {
265            (applet_padding_minor_axis, applet_padding_major_axis)
266        };
267        crate::widget::button::custom(
268            layer_container(
269                Text::from(text)
270                    .height(Length::Fill)
271                    .align_y(Alignment::Center),
272            )
273            .center_y(Length::Fixed(f32::from(suggested.1 + 2 * vertical_padding))),
274        )
275        .on_press_down(message)
276        .padding([0, horizontal_padding])
277        .class(crate::theme::Button::AppletIcon)
278    }
279
280    pub fn icon_button<'a, Message: Clone + 'static>(
281        &self,
282        icon_name: &'a str,
283    ) -> crate::widget::Button<'a, Message> {
284        let suggested_size = self.suggested_size(true);
285        self.icon_button_from_handle(
286            widget::icon::from_name(icon_name)
287                .symbolic(true)
288                .size(suggested_size.0)
289                .into(),
290        )
291    }
292
293    pub fn applet_tooltip<'a, Message: 'static>(
294        &self,
295        content: impl Into<Element<'a, Message>>,
296        tooltip: impl Into<Cow<'static, str>>,
297        has_popup: bool,
298        on_surface_action: impl Fn(crate::surface::Action) -> Message + 'static,
299        parent_id: Option<window::Id>,
300    ) -> crate::widget::wayland::tooltip::widget::Tooltip<'a, Message, Message> {
301        let window_id = *TOOLTIP_WINDOW_ID;
302        let subsurface_id = TOOLTIP_ID.clone();
303        let anchor = self.anchor;
304        let tooltip = tooltip.into();
305
306        crate::widget::wayland::tooltip::widget::Tooltip::<'a, Message, Message>::new(
307            content,
308            (!has_popup).then_some(move |bounds: Rectangle| {
309                let window_id = window_id;
310                let (popup_anchor, gravity) = match anchor {
311                    PanelAnchor::Left => (Anchor::Right, Gravity::Right),
312                    PanelAnchor::Right => (Anchor::Left, Gravity::Left),
313                    PanelAnchor::Top => (Anchor::Bottom, Gravity::Bottom),
314                    PanelAnchor::Bottom => (Anchor::Top, Gravity::Top),
315                };
316
317                SctkPopupSettings {
318                    parent: parent_id.unwrap_or(window::Id::RESERVED),
319                    id: window_id,
320                    grab: false,
321                    input_zone: Some(vec![Rectangle::new(
322                        iced::Point::new(-1000., -1000.),
323                        iced::Size::default(),
324                    )]),
325                    positioner: SctkPositioner {
326                        size: None,
327                        size_limits: Limits::NONE.min_width(1.).min_height(1.),
328                        anchor_rect: Rectangle {
329                            x: bounds.x.round() as i32,
330                            y: bounds.y.round() as i32,
331                            width: bounds.width.round() as i32,
332                            height: bounds.height.round() as i32,
333                        },
334                        anchor: popup_anchor,
335                        gravity,
336                        constraint_adjustment: 15,
337                        offset: (0, 0),
338                        reactive: true,
339                    },
340                    parent_size: None,
341                    close_with_children: true,
342                }
343            }),
344            move || {
345                Element::from(autosize::autosize(
346                    layer_container(crate::widget::text(tooltip.clone()))
347                        .layer(crate::cosmic_theme::Layer::Background)
348                        .padding(4.),
349                    subsurface_id.clone(),
350                ))
351            },
352            on_surface_action(crate::surface::Action::DestroyPopup(window_id)),
353            on_surface_action,
354        )
355        .delay(Duration::from_millis(100))
356    }
357
358    // TODO popup container which tracks the size of itself and requests the popup to resize to match
359    pub fn popup_container<'a, Message: 'static>(
360        &self,
361        content: impl Into<Element<'a, Message>>,
362    ) -> Autosize<'a, Message, crate::Theme, Renderer> {
363        let (vertical_align, horizontal_align) = match self.anchor {
364            PanelAnchor::Left => (Vertical::Center, Horizontal::Left),
365            PanelAnchor::Right => (Vertical::Center, Horizontal::Right),
366            PanelAnchor::Top => (Vertical::Top, Horizontal::Center),
367            PanelAnchor::Bottom => (Vertical::Bottom, Horizontal::Center),
368        };
369
370        autosize(
371            Container::<Message, _, Renderer>::new(
372                Container::<Message, _, Renderer>::new(content).style(|theme| {
373                    let cosmic = theme.cosmic();
374                    let corners = cosmic.corner_radii;
375                    let mut bg = cosmic.background(theme.transparent).base;
376                    iced_widget::container::Style {
377                        text_color: Some(cosmic.background(theme.transparent).on.into()),
378                        background: Some(Color::from(bg).into()),
379                        border: iced::Border {
380                            radius: corners.radius_m.into(),
381                            width: 1.0,
382                            color: cosmic.background(theme.transparent).divider.into(),
383                        },
384                        shadow: Shadow::default(),
385                        icon_color: Some(cosmic.background(theme.transparent).on.into()),
386                        snap: true,
387                    }
388                }),
389            )
390            .height(Length::Shrink)
391            .align_x(horizontal_align)
392            .align_y(vertical_align),
393            AUTOSIZE_ID.clone(),
394        )
395        .limits(
396            Limits::NONE
397                .min_height(1.)
398                .min_width(360.0)
399                .max_width(360.0)
400                .max_height(1000.0),
401        )
402    }
403
404    #[must_use]
405    #[allow(clippy::cast_possible_wrap)]
406    pub fn get_popup_settings(
407        &self,
408        parent: window::Id,
409        id: window::Id,
410        size: Option<(u32, u32)>,
411        width_padding: Option<i32>,
412        height_padding: Option<i32>,
413    ) -> SctkPopupSettings {
414        let (width, height) = self.suggested_size(true);
415        let (applet_padding_major_axis, applet_padding_minor_axis) = self.suggested_padding(true);
416        let (horizontal_padding, vertical_padding) = if self.is_horizontal() {
417            (applet_padding_major_axis, applet_padding_minor_axis)
418        } else {
419            (applet_padding_minor_axis, applet_padding_major_axis)
420        };
421        let pixel_offset = 4;
422        let (offset, anchor, gravity) = match self.anchor {
423            PanelAnchor::Left => ((pixel_offset, 0), Anchor::Right, Gravity::Right),
424            PanelAnchor::Right => ((-pixel_offset, 0), Anchor::Left, Gravity::Left),
425            PanelAnchor::Top => ((0, pixel_offset), Anchor::Bottom, Gravity::Bottom),
426            PanelAnchor::Bottom => ((0, -pixel_offset), Anchor::Top, Gravity::Top),
427        };
428        SctkPopupSettings {
429            parent,
430            id,
431            positioner: SctkPositioner {
432                anchor,
433                gravity,
434                offset,
435                size,
436                anchor_rect: Rectangle {
437                    x: 0,
438                    y: 0,
439                    width: width_padding.unwrap_or(horizontal_padding as i32) * 2
440                        + i32::from(width),
441                    height: height_padding.unwrap_or(vertical_padding as i32) * 2
442                        + i32::from(height),
443                },
444                reactive: true,
445                constraint_adjustment: 15, // slide_y, slide_x, flip_x, flip_y
446                size_limits: Limits::NONE
447                    .min_height(1.0)
448                    .min_width(360.0)
449                    .max_width(360.0)
450                    .max_height(1080.0),
451            },
452            parent_size: None,
453            grab: true,
454            close_with_children: false,
455            input_zone: None,
456        }
457    }
458
459    pub fn autosize_window<'a, Message: 'static>(
460        &self,
461        content: impl Into<Element<'a, Message>>,
462    ) -> Autosize<'a, Message, crate::Theme, crate::Renderer> {
463        let force_configured = matches!(&self.panel_type, PanelType::Other(n) if n.is_empty());
464        let w = autosize(content, AUTOSIZE_MAIN_ID.clone());
465        let mut limits = Limits::NONE;
466        let suggested_window_size = self.suggested_window_size();
467
468        if let Some(width) = self
469            .suggested_bounds
470            .as_ref()
471            .filter(|c| c.width as i32 > 0)
472            .map(|c| c.width)
473        {
474            limits = limits.width(width);
475        }
476        if let Some(height) = self
477            .suggested_bounds
478            .as_ref()
479            .filter(|c| c.height as i32 > 0)
480            .map(|c| c.height)
481        {
482            limits = limits.height(height);
483        }
484
485        w.limits(limits)
486    }
487
488    #[must_use]
489    pub fn theme(&self) -> Option<theme::Theme> {
490        match self.background {
491            CosmicPanelBackground::Dark => {
492                let mut theme = system_dark();
493                theme.theme_type.prefer_dark(Some(true));
494                Some(theme)
495            }
496            CosmicPanelBackground::Light => {
497                let mut theme = system_light();
498                theme.theme_type.prefer_dark(Some(false));
499                Some(theme)
500            }
501            _ => Some(theme::system_preference()),
502        }
503    }
504
505    pub fn text<'a>(&self, msg: impl Into<Cow<'a, str>>) -> crate::widget::Text<'a, crate::Theme> {
506        let msg = msg.into();
507        let t = match self.size {
508            Size::Hardcoded(_) => crate::widget::text,
509            Size::PanelSize(ref s) => {
510                let size = s.get_applet_icon_size_with_padding(false);
511
512                let size_threshold_small = PanelSize::S.get_applet_icon_size_with_padding(false);
513                let size_threshold_medium = PanelSize::M.get_applet_icon_size_with_padding(false);
514                let size_threshold_large = PanelSize::L.get_applet_icon_size_with_padding(false);
515
516                if size <= size_threshold_small {
517                    crate::widget::text::body
518                } else if size <= size_threshold_medium {
519                    crate::widget::text::title4
520                } else if size <= size_threshold_large {
521                    crate::widget::text::title3
522                } else {
523                    crate::widget::text::title2
524                }
525            }
526        };
527        t(msg).font(crate::font::default())
528    }
529}
530
531/// Launch the application with the given settings.
532///
533/// # Errors
534///
535/// Returns error on application failure.
536pub fn run<App: Application>(flags: App::Flags) -> iced::Result {
537    let helper = Context::default();
538
539    let mut settings = helper.window_settings();
540    settings.resizable = None;
541
542    #[cfg(all(target_env = "gnu", not(target_os = "windows")))]
543    if let Some(threshold) = settings.default_mmap_threshold {
544        crate::malloc::limit_mmap_threshold(threshold);
545    }
546
547    if let Some(icon_theme) = settings.default_icon_theme.as_ref() {
548        crate::icon_theme::set_default(icon_theme.clone());
549    }
550
551    THEME
552        .lock()
553        .unwrap()
554        .set_theme(settings.theme.theme_type.clone());
555
556    let (iced_settings, (mut core, flags), mut window_settings) =
557        iced_settings::<App>(settings, flags);
558    core.window.show_headerbar = false;
559    core.window.sharp_corners = true;
560    core.window.show_maximize = false;
561    core.window.show_minimize = false;
562    core.window.use_template = false;
563    core.app_type = crate::core::AppType::Applet;
564
565    window_settings.decorations = false;
566    window_settings.exit_on_close_request = true;
567    window_settings.resizable = false;
568    window_settings.resize_border = 0;
569
570    // TODO make multi-window not mandatory
571
572    let no_main_window = core.main_window.is_none();
573    if no_main_window {
574        // TODO still apply window settings?
575        // window_settings = window_settings.clone();
576        core.main_window = Some(iced_core::window::Id::RESERVED);
577    }
578    let mut app = iced::daemon(
579        BootData(Rc::new(RefCell::new(Some(BootDataInner::<App> {
580            flags,
581            core,
582            settings: window_settings,
583        })))),
584        cosmic::Cosmic::update,
585        cosmic::Cosmic::view,
586    );
587
588    app.subscription(cosmic::Cosmic::subscription)
589        .style(cosmic::Cosmic::style)
590        .theme(cosmic::Cosmic::theme)
591        .settings(iced_settings)
592        .run()
593}
594
595#[must_use]
596pub fn style() -> iced::theme::Style {
597    let theme = crate::theme::THEME.lock().unwrap();
598    iced::theme::Style {
599        background_color: Color::from_rgba(0.0, 0.0, 0.0, 0.0),
600        text_color: theme.cosmic().on_bg_color().into(),
601        icon_color: theme.cosmic().on_bg_color().into(),
602    }
603}
604
605pub fn menu_button<'a, Message: Clone + 'a>(
606    content: impl Into<Element<'a, Message>>,
607) -> crate::widget::Button<'a, Message> {
608    crate::widget::button::custom(content)
609        .class(Button::AppletMenu)
610        .padding(menu_control_padding())
611        .width(Length::Fill)
612}
613
614pub fn padded_control<'a, Message>(
615    content: impl Into<Element<'a, Message>>,
616) -> crate::widget::container::Container<'a, Message, crate::Theme, crate::Renderer> {
617    crate::widget::container(content)
618        .padding(menu_control_padding())
619        .width(Length::Fill)
620}
621
622pub fn menu_control_padding() -> Padding {
623    let guard = THEME.lock().unwrap();
624    let cosmic = guard.cosmic();
625    [cosmic.space_xxs(), cosmic.space_m()].into()
626}