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}
781