cosmic/applet/
mod.rs

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