sctk_adwaita/
lib.rs

1use std::error::Error;
2use std::mem;
3use std::num::NonZeroU32;
4use std::sync::Arc;
5use std::time::Duration;
6
7use tiny_skia::{
8    Color, FillRule, Mask, Path, PathBuilder, Pixmap, PixmapMut, PixmapPaint, Point, Rect,
9    Transform,
10};
11
12use smithay_client_toolkit::reexports::client::backend::ObjectId;
13use smithay_client_toolkit::reexports::client::protocol::wl_shm;
14use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface;
15use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
16use smithay_client_toolkit::reexports::client::{Dispatch, Proxy, QueueHandle};
17use smithay_client_toolkit::reexports::csd_frame::{
18    CursorIcon, DecorationsFrame, FrameAction, FrameClick, WindowManagerCapabilities, WindowState,
19};
20
21use smithay_client_toolkit::compositor::{CompositorState, Region, SurfaceData};
22use smithay_client_toolkit::shell::WaylandSurface;
23use smithay_client_toolkit::shm::{slot::SlotPool, Shm};
24use smithay_client_toolkit::subcompositor::SubcompositorState;
25use smithay_client_toolkit::subcompositor::SubsurfaceData;
26
27mod buttons;
28mod config;
29mod parts;
30mod pointer;
31mod shadow;
32pub mod theme;
33mod title;
34mod wl_typed;
35
36use crate::theme::{
37    ColorMap, ColorTheme, BORDER_SIZE, CORNER_RADIUS, HEADER_SIZE, RESIZE_HANDLE_CORNER_SIZE,
38    VISIBLE_BORDER_SIZE,
39};
40
41use buttons::Buttons;
42use config::get_button_layout_config;
43use parts::DecorationParts;
44use pointer::{Location, MouseState};
45use shadow::Shadow;
46use title::TitleText;
47use wl_typed::WlTyped;
48
49/// XXX this is not result, so `must_use` when needed.
50type SkiaResult = Option<()>;
51
52/// A simple set of decorations
53#[derive(Debug)]
54pub struct AdwaitaFrame<State> {
55    /// The base surface used to create the window.
56    base_surface: WlTyped<WlSurface, SurfaceData>,
57
58    compositor: Arc<CompositorState>,
59
60    /// Subcompositor to create/drop subsurfaces ondemand.
61    subcompositor: Arc<SubcompositorState>,
62
63    /// Queue handle to perform object creation.
64    queue_handle: QueueHandle<State>,
65
66    /// The drawable decorations, `None` when hidden.
67    decorations: Option<DecorationParts>,
68
69    /// Memory pool to allocate the buffers for the decorations.
70    pool: SlotPool,
71
72    /// Whether the frame should be redrawn.
73    dirty: bool,
74
75    /// Whether the drawing should be synced with the main surface.
76    should_sync: bool,
77
78    /// Scale factor used for the surface.
79    scale_factor: u32,
80
81    /// Wether the frame is resizable.
82    resizable: bool,
83
84    buttons: Buttons,
85    state: WindowState,
86    wm_capabilities: WindowManagerCapabilities,
87    mouse: MouseState,
88    theme: ColorTheme,
89    title: Option<String>,
90    title_text: Option<TitleText>,
91    shadow: Shadow,
92}
93
94impl<State> AdwaitaFrame<State>
95where
96    State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
97{
98    pub fn new(
99        base_surface: &impl WaylandSurface,
100        shm: &Shm,
101        compositor: Arc<CompositorState>,
102        subcompositor: Arc<SubcompositorState>,
103        queue_handle: QueueHandle<State>,
104        frame_config: FrameConfig,
105    ) -> Result<Self, Box<dyn Error>> {
106        let base_surface = WlTyped::wrap::<State>(base_surface.wl_surface().clone());
107
108        let pool = SlotPool::new(1, shm)?;
109
110        let decorations = Some(DecorationParts::new(
111            &base_surface,
112            &subcompositor,
113            &queue_handle,
114        ));
115
116        let theme = frame_config.theme;
117
118        Ok(AdwaitaFrame {
119            base_surface,
120            decorations,
121            pool,
122            compositor,
123            subcompositor,
124            queue_handle,
125            dirty: true,
126            scale_factor: 1,
127            should_sync: true,
128            title: None,
129            title_text: TitleText::new(theme.active.font_color),
130            theme,
131            buttons: Buttons::new(get_button_layout_config()),
132            mouse: Default::default(),
133            state: WindowState::empty(),
134            wm_capabilities: WindowManagerCapabilities::all(),
135            resizable: true,
136            shadow: Shadow::default(),
137        })
138    }
139
140    /// Update the current frame config.
141    pub fn set_config(&mut self, config: FrameConfig) {
142        self.theme = config.theme;
143        self.dirty = true;
144    }
145
146    fn precise_location(
147        &self,
148        location: Location,
149        decoration: &DecorationParts,
150        x: f64,
151        y: f64,
152    ) -> Location {
153        let header_width = decoration.header().surface_rect.width;
154        let side_height = decoration.side_height();
155
156        let left_corner_x = BORDER_SIZE + RESIZE_HANDLE_CORNER_SIZE;
157        let right_corner_x = (header_width + BORDER_SIZE).saturating_sub(RESIZE_HANDLE_CORNER_SIZE);
158        let top_corner_y = RESIZE_HANDLE_CORNER_SIZE;
159        let bottom_corner_y = side_height.saturating_sub(RESIZE_HANDLE_CORNER_SIZE);
160        match location {
161            Location::Head | Location::Button(_) => self.buttons.find_button(x, y),
162            Location::Top | Location::TopLeft | Location::TopRight => {
163                if x <= f64::from(left_corner_x) {
164                    Location::TopLeft
165                } else if x >= f64::from(right_corner_x) {
166                    Location::TopRight
167                } else {
168                    Location::Top
169                }
170            }
171            Location::Bottom | Location::BottomLeft | Location::BottomRight => {
172                if x <= f64::from(left_corner_x) {
173                    Location::BottomLeft
174                } else if x >= f64::from(right_corner_x) {
175                    Location::BottomRight
176                } else {
177                    Location::Bottom
178                }
179            }
180            Location::Left => {
181                if y <= f64::from(top_corner_y) {
182                    Location::TopLeft
183                } else if y >= f64::from(bottom_corner_y) {
184                    Location::BottomLeft
185                } else {
186                    Location::Left
187                }
188            }
189            Location::Right => {
190                if y <= f64::from(top_corner_y) {
191                    Location::TopRight
192                } else if y >= f64::from(bottom_corner_y) {
193                    Location::BottomRight
194                } else {
195                    Location::Right
196                }
197            }
198            other => other,
199        }
200    }
201
202    fn redraw_inner(&mut self) -> Option<bool> {
203        let decorations = self.decorations.as_mut()?;
204
205        // Reset the dirty bit.
206        self.dirty = false;
207        let should_sync = mem::take(&mut self.should_sync);
208
209        // Don't draw borders if the frame explicitly hidden or fullscreened.
210        if self.state.contains(WindowState::FULLSCREEN) {
211            decorations.hide();
212            return Some(true);
213        }
214
215        let colors = if self.state.contains(WindowState::ACTIVATED) {
216            &self.theme.active
217        } else {
218            &self.theme.inactive
219        };
220
221        let draw_borders = if self.state.contains(WindowState::MAXIMIZED) {
222            // Don't draw the borders.
223            decorations.hide_borders();
224            false
225        } else {
226            true
227        };
228        let border_paint = colors.border_paint();
229
230        // Draw the borders.
231        for (idx, part) in decorations
232            .parts()
233            .filter(|(idx, _)| *idx == DecorationParts::HEADER || draw_borders)
234        {
235            let scale = self.scale_factor;
236
237            let mut rect = part.surface_rect;
238            // XXX to perfectly align the visible borders we draw them with
239            // the header, otherwise rounded corners won't look 'smooth' at the
240            // start. To achieve that, we enlargen the width of the header by
241            // 2 * `VISIBLE_BORDER_SIZE`, and move `x` by `VISIBLE_BORDER_SIZE`
242            // to the left.
243            if idx == DecorationParts::HEADER && draw_borders {
244                rect.width += 2 * VISIBLE_BORDER_SIZE;
245                rect.x -= VISIBLE_BORDER_SIZE as i32;
246            }
247
248            rect.width *= scale;
249            rect.height *= scale;
250
251            let (buffer, canvas) = match self.pool.create_buffer(
252                rect.width as i32,
253                rect.height as i32,
254                rect.width as i32 * 4,
255                wl_shm::Format::Argb8888,
256            ) {
257                Ok((buffer, canvas)) => (buffer, canvas),
258                Err(_) => continue,
259            };
260
261            // Create the pixmap and fill with transparent color.
262            let mut pixmap = PixmapMut::from_bytes(canvas, rect.width, rect.height)?;
263
264            // Fill everything with transparent background, since we draw rounded corners and
265            // do invisible borders to enlarge the input zone.
266            pixmap.fill(Color::TRANSPARENT);
267
268            if !self.state.intersects(WindowState::TILED) {
269                self.shadow.draw(
270                    &mut pixmap,
271                    scale,
272                    self.state.contains(WindowState::ACTIVATED),
273                    idx,
274                );
275            }
276
277            match idx {
278                DecorationParts::HEADER => {
279                    if let Some(title_text) = self.title_text.as_mut() {
280                        title_text.update_scale(scale);
281                        title_text.update_color(colors.font_color);
282                    }
283
284                    draw_headerbar(
285                        &mut pixmap,
286                        self.title_text.as_ref().map(|t| t.pixmap()).unwrap_or(None),
287                        scale as f32,
288                        self.resizable,
289                        &self.state,
290                        &self.theme,
291                        &self.buttons,
292                        self.mouse.location,
293                    );
294                }
295                border => {
296                    // The visible border is one pt.
297                    let visible_border_size = VISIBLE_BORDER_SIZE * scale;
298
299                    // XXX we do all the match using integral types and then convert to f32 in the
300                    // end to ensure that result is finite.
301                    let border_rect = match border {
302                        DecorationParts::LEFT => {
303                            let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
304                            let y = rect.y.unsigned_abs() * scale;
305                            Rect::from_xywh(
306                                x as f32,
307                                y as f32,
308                                visible_border_size as f32,
309                                (rect.height - y) as f32,
310                            )
311                        }
312                        DecorationParts::RIGHT => {
313                            let y = rect.y.unsigned_abs() * scale;
314                            Rect::from_xywh(
315                                0.,
316                                y as f32,
317                                visible_border_size as f32,
318                                (rect.height - y) as f32,
319                            )
320                        }
321                        // We draw small visible border only bellow the window surface, no need to
322                        // handle `TOP`.
323                        DecorationParts::BOTTOM => {
324                            let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
325                            Rect::from_xywh(
326                                x as f32,
327                                0.,
328                                (rect.width - 2 * x) as f32,
329                                visible_border_size as f32,
330                            )
331                        }
332                        _ => None,
333                    };
334
335                    // Fill the visible border, if present.
336                    if let Some(border_rect) = border_rect {
337                        pixmap.fill_rect(border_rect, &border_paint, Transform::identity(), None);
338                    }
339                }
340            };
341
342            if should_sync {
343                part.subsurface.set_sync();
344            } else {
345                part.subsurface.set_desync();
346            }
347
348            part.surface.set_buffer_scale(scale as i32);
349
350            part.subsurface.set_position(rect.x, rect.y);
351            buffer.attach_to(&part.surface).ok()?;
352
353            if part.surface.version() >= 4 {
354                part.surface.damage_buffer(0, 0, i32::MAX, i32::MAX);
355            } else {
356                part.surface.damage(0, 0, i32::MAX, i32::MAX);
357            }
358
359            if let Some(input_rect) = part.input_rect {
360                let input_region = Region::new(&*self.compositor).ok()?;
361                input_region.add(
362                    input_rect.x,
363                    input_rect.y,
364                    input_rect.width as i32,
365                    input_rect.height as i32,
366                );
367
368                part.surface
369                    .set_input_region(Some(input_region.wl_region()));
370            }
371
372            part.surface.commit();
373        }
374
375        Some(should_sync)
376    }
377}
378
379impl<State> DecorationsFrame for AdwaitaFrame<State>
380where
381    State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
382{
383    fn update_state(&mut self, state: WindowState) {
384        let difference = self.state.symmetric_difference(state);
385        self.state = state;
386        self.dirty |= difference.intersects(
387            WindowState::ACTIVATED
388                | WindowState::FULLSCREEN
389                | WindowState::MAXIMIZED
390                | WindowState::TILED,
391        );
392    }
393
394    fn update_wm_capabilities(&mut self, wm_capabilities: WindowManagerCapabilities) {
395        self.dirty |= self.wm_capabilities != wm_capabilities;
396        self.wm_capabilities = wm_capabilities;
397        self.buttons.update_wm_capabilities(wm_capabilities);
398    }
399
400    fn set_hidden(&mut self, hidden: bool) {
401        if hidden {
402            self.dirty = false;
403            let _ = self.pool.resize(1);
404            self.decorations = None;
405        } else if self.decorations.is_none() {
406            self.decorations = Some(DecorationParts::new(
407                &self.base_surface,
408                &self.subcompositor,
409                &self.queue_handle,
410            ));
411            self.dirty = true;
412            self.should_sync = true;
413        }
414    }
415
416    fn set_resizable(&mut self, resizable: bool) {
417        self.dirty |= self.resizable != resizable;
418        self.resizable = resizable;
419    }
420
421    fn resize(&mut self, width: NonZeroU32, height: NonZeroU32) {
422        let Some(decorations) = self.decorations.as_mut() else {
423            log::error!("trying to resize the hidden frame.");
424            return;
425        };
426
427        decorations.resize(width.get(), height.get());
428        self.buttons
429            .arrange(width.get(), get_margin_h_lp(&self.state));
430        self.dirty = true;
431        self.should_sync = true;
432    }
433
434    fn draw(&mut self) -> bool {
435        self.redraw_inner().unwrap_or(true)
436    }
437
438    fn subtract_borders(
439        &self,
440        width: NonZeroU32,
441        height: NonZeroU32,
442    ) -> (Option<NonZeroU32>, Option<NonZeroU32>) {
443        if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
444            (Some(width), Some(height))
445        } else {
446            (
447                Some(width),
448                NonZeroU32::new(height.get().saturating_sub(HEADER_SIZE)),
449            )
450        }
451    }
452
453    fn add_borders(&self, width: u32, height: u32) -> (u32, u32) {
454        if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
455            (width, height)
456        } else {
457            (width, height + HEADER_SIZE)
458        }
459    }
460
461    fn location(&self) -> (i32, i32) {
462        if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
463            (0, 0)
464        } else {
465            (0, -(HEADER_SIZE as i32))
466        }
467    }
468
469    fn set_title(&mut self, title: impl Into<String>) {
470        let new_title = title.into();
471        if let Some(title_text) = self.title_text.as_mut() {
472            title_text.update_title(new_title.clone());
473        }
474
475        self.title = Some(new_title);
476        self.dirty = true;
477    }
478
479    fn on_click(
480        &mut self,
481        timestamp: Duration,
482        click: FrameClick,
483        pressed: bool,
484    ) -> Option<FrameAction> {
485        match click {
486            FrameClick::Normal => self.mouse.click(
487                timestamp,
488                pressed,
489                self.resizable,
490                &self.state,
491                &self.wm_capabilities,
492            ),
493            FrameClick::Alternate => self.mouse.alternate_click(pressed, &self.wm_capabilities),
494            _ => None,
495        }
496    }
497
498    fn set_scaling_factor(&mut self, scale_factor: f64) {
499        // NOTE: Clamp it just in case to some ok-ish range.
500        self.scale_factor = scale_factor.clamp(0.1, 64.).ceil() as u32;
501        self.dirty = true;
502        self.should_sync = true;
503    }
504
505    fn click_point_moved(
506        &mut self,
507        _timestamp: Duration,
508        surface: &ObjectId,
509        x: f64,
510        y: f64,
511    ) -> Option<CursorIcon> {
512        let decorations = self.decorations.as_ref()?;
513        let location = decorations.find_surface(surface);
514        if location == Location::None {
515            return None;
516        }
517
518        let old_location = self.mouse.location;
519
520        let location = self.precise_location(location, decorations, x, y);
521        let new_cursor = self.mouse.moved(location, x, y, self.resizable);
522
523        // Set dirty if we moved the cursor between the buttons.
524        self.dirty |= (matches!(old_location, Location::Button(_))
525            || matches!(self.mouse.location, Location::Button(_)))
526            && old_location != self.mouse.location;
527
528        Some(new_cursor)
529    }
530
531    fn click_point_left(&mut self) {
532        self.mouse.left()
533    }
534
535    fn is_dirty(&self) -> bool {
536        self.dirty
537    }
538
539    fn is_hidden(&self) -> bool {
540        self.decorations.is_none()
541    }
542}
543
544/// The configuration for the [`AdwaitaFrame`] frame.
545#[derive(Debug, Clone)]
546pub struct FrameConfig {
547    pub theme: ColorTheme,
548}
549
550impl FrameConfig {
551    /// Create the new configuration with the given `theme`.
552    pub fn new(theme: ColorTheme) -> Self {
553        Self { theme }
554    }
555
556    /// This is equivalent of calling `FrameConfig::new(ColorTheme::auto())`.
557    ///
558    /// For details see [`ColorTheme::auto`].
559    pub fn auto() -> Self {
560        Self {
561            theme: ColorTheme::auto(),
562        }
563    }
564
565    /// This is equivalent of calling `FrameConfig::new(ColorTheme::light())`.
566    ///
567    /// For details see [`ColorTheme::light`].
568    pub fn light() -> Self {
569        Self {
570            theme: ColorTheme::light(),
571        }
572    }
573
574    /// This is equivalent of calling `FrameConfig::new(ColorTheme::dark())`.
575    ///
576    /// For details see [`ColorTheme::dark`].
577    pub fn dark() -> Self {
578        Self {
579            theme: ColorTheme::dark(),
580        }
581    }
582}
583
584#[allow(clippy::too_many_arguments)]
585fn draw_headerbar(
586    pixmap: &mut PixmapMut,
587    text_pixmap: Option<&Pixmap>,
588    scale: f32,
589    resizable: bool,
590    state: &WindowState,
591    theme: &ColorTheme,
592    buttons: &Buttons,
593    mouse: Location,
594) {
595    let colors = theme.for_state(state.contains(WindowState::ACTIVATED));
596
597    let _ = draw_headerbar_bg(pixmap, scale, colors, state);
598
599    // Horizontal margin.
600    let margin_h = get_margin_h_lp(state) * 2.0;
601
602    let canvas_w = pixmap.width() as f32;
603    let canvas_h = pixmap.height() as f32;
604
605    let header_w = canvas_w - margin_h * 2.0;
606    let header_h = canvas_h;
607
608    if let Some(text_pixmap) = text_pixmap {
609        const TEXT_OFFSET: f32 = 10.;
610        let offset_x = TEXT_OFFSET * scale;
611
612        let text_w = text_pixmap.width() as f32;
613        let text_h = text_pixmap.height() as f32;
614
615        let x = margin_h + header_w / 2. - text_w / 2.;
616        let y = header_h / 2. - text_h / 2.;
617
618        let left_buttons_end_x = buttons.left_buttons_end_x().unwrap_or(0.0) * scale;
619        let right_buttons_start_x =
620            buttons.right_buttons_start_x().unwrap_or(header_w / scale) * scale;
621
622        {
623            // We have enough space to center text
624            let (x, y, text_canvas_start_x) = if (x + text_w < right_buttons_start_x - offset_x)
625                && (x > left_buttons_end_x + offset_x)
626            {
627                let text_canvas_start_x = x;
628
629                (x, y, text_canvas_start_x)
630            } else {
631                let x = left_buttons_end_x + offset_x;
632                let text_canvas_start_x = left_buttons_end_x + offset_x;
633
634                (x, y, text_canvas_start_x)
635            };
636
637            let text_canvas_end_x = right_buttons_start_x - x - offset_x;
638            // Ensure that text start within the bounds.
639            let x = x.max(margin_h + offset_x);
640
641            if let Some(clip) =
642                Rect::from_xywh(text_canvas_start_x, 0., text_canvas_end_x, canvas_h)
643            {
644                if let Some(mut mask) = Mask::new(canvas_w as u32, canvas_h as u32) {
645                    mask.fill_path(
646                        &PathBuilder::from_rect(clip),
647                        FillRule::Winding,
648                        false,
649                        Transform::identity(),
650                    );
651                    pixmap.draw_pixmap(
652                        x.round() as i32,
653                        y as i32,
654                        text_pixmap.as_ref(),
655                        &PixmapPaint::default(),
656                        Transform::identity(),
657                        Some(&mask),
658                    );
659                } else {
660                    log::error!(
661                        "Invalid mask width and height: w: {}, h: {}",
662                        canvas_w as u32,
663                        canvas_h as u32
664                    );
665                }
666            }
667        }
668    }
669
670    // Draw the buttons.
671    buttons.draw(
672        margin_h, header_w, scale, colors, mouse, pixmap, resizable, state,
673    );
674}
675
676#[must_use]
677fn draw_headerbar_bg(
678    pixmap: &mut PixmapMut,
679    scale: f32,
680    colors: &ColorMap,
681    state: &WindowState,
682) -> SkiaResult {
683    let w = pixmap.width() as f32;
684    let h = pixmap.height() as f32;
685
686    let radius = if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {
687        0.
688    } else {
689        CORNER_RADIUS as f32 * scale
690    };
691
692    let bg = rounded_headerbar_shape(0., 0., w, h, radius)?;
693
694    pixmap.fill_path(
695        &bg,
696        &colors.headerbar_paint(),
697        FillRule::Winding,
698        Transform::identity(),
699        None,
700    );
701
702    pixmap.fill_rect(
703        Rect::from_xywh(0., h - 1., w, h)?,
704        &colors.border_paint(),
705        Transform::identity(),
706        None,
707    );
708
709    Some(())
710}
711
712fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Option<Path> {
713    // https://stackoverflow.com/a/27863181
714    let cubic_bezier_circle = 0.552_284_8 * radius;
715
716    let mut pb = PathBuilder::new();
717    let mut cursor = Point::from_xy(x, y);
718
719    // !!!
720    // This code is heavily "inspired" by https://gitlab.com/snakedye/snui/
721    // So technically it should be licensed under MPL-2.0, sorry about that 🥺 👉👈
722    // !!!
723
724    // Positioning the cursor
725    cursor.y += radius;
726    pb.move_to(cursor.x, cursor.y);
727
728    // Drawing the outline
729    let next = Point::from_xy(cursor.x + radius, cursor.y - radius);
730    pb.cubic_to(
731        cursor.x,
732        cursor.y - cubic_bezier_circle,
733        next.x - cubic_bezier_circle,
734        next.y,
735        next.x,
736        next.y,
737    );
738    cursor = next;
739    pb.line_to(
740        {
741            cursor.x = x + width - radius;
742            cursor.x
743        },
744        cursor.y,
745    );
746    let next = Point::from_xy(cursor.x + radius, cursor.y + radius);
747    pb.cubic_to(
748        cursor.x + cubic_bezier_circle,
749        cursor.y,
750        next.x,
751        next.y - cubic_bezier_circle,
752        next.x,
753        next.y,
754    );
755    cursor = next;
756    pb.line_to(cursor.x, {
757        cursor.y = y + height;
758        cursor.y
759    });
760    pb.line_to(
761        {
762            cursor.x = x;
763            cursor.x
764        },
765        cursor.y,
766    );
767
768    pb.close();
769
770    pb.finish()
771}
772
773// returns horizontal margin, logical points
774fn get_margin_h_lp(state: &WindowState) -> f32 {
775    if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {
776        0.
777    } else {
778        VISIBLE_BORDER_SIZE as f32
779    }
780}