// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Surface chrome: the client-side title bar and the Wayland input //! region. Both are backend-agnostic — the software and GPU draw //! paths call them with a `&mut Canvas` / a `&WlSurface` and get back //! the geometry they need for hit testing + commit. use smithay_client_toolkit::compositor::{ CompositorState, Region }; use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; use crate::render::Canvas; use crate::types::{ Color, Rect }; /// Declare the opaque part of the surface: the whole of it when the /// background is opaque, else the app's `region` (physical rects), so the /// compositor can skip blending it and repainting what lies underneath. /// `applied` remembers the rects last sent. pub( crate ) fn sync_opaque_region( wl_surface: &WlSurface, compositor: &CompositorState, applied: &mut Option>, bg: Color, region: Option<&[Rect]>, width: u32, height: u32, scale: u32, ) { let s = scale.max( 1 ) as f32; let rects: Vec = if bg.a >= 1.0 { vec![ Rect { x: 0.0, y: 0.0, width: width as f32 * s, height: height as f32 * s } ] } else { region.map( |r| r.to_vec() ).unwrap_or_default() }; if applied.as_ref() == Some( &rects ) { return; } if rects.is_empty() { wl_surface.set_opaque_region( None ); } else if let Ok( wl_region ) = Region::new( compositor ) { for r in &rects { wl_region.add( ( r.x / s ).round() as i32, ( r.y / s ).round() as i32, ( r.width / s ).round() as i32, ( r.height / s ).round() as i32, ); } wl_surface.set_opaque_region( Some( wl_region.wl_region() ) ); } *applied = Some( rects ); } /// Paint the client-side title bar (close button, divider, title text). /// /// Returns the close button rect in physical pixels so the caller can /// store it for hit testing. Returns `Rect::default()` when `tb_h <= /// 0` — overlays / layer-shell surfaces have no title bar. pub( crate ) fn draw_titlebar( canvas: &mut Canvas, title: &str, pw: u32, tb_h: f32, sf: f32 ) -> Rect { if tb_h <= 0.0 { return Rect::default(); } let tb_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: tb_h }; canvas.fill_rect( tb_rect, Color::rgb( 0.15, 0.15, 0.18 ), 0.0 ); let title_size = 15.0; // `Canvas::draw_text` interprets `y` as the baseline. Match the // vertical-centring formula used by `Button::draw_text_button` so // chrome and content text lines line up at the same optical height. let title_y = ( tb_h + title_size * sf ) / 2.0 - 2.0; let title_w = canvas.measure_text( title, title_size ); let title_x = ( pw as f32 - title_w ) / 2.0; canvas.draw_text( title, title_x, title_y, title_size, Color::WHITE ); let btn_size = tb_h - 8.0 * sf; let btn_x = pw as f32 - btn_size - 8.0 * sf; let btn_y = 4.0 * sf; let close_rect_phys = Rect { x: btn_x, y: btn_y, width: btn_size, height: btn_size }; canvas.fill_rect( close_rect_phys, Color::rgba( 1.0, 1.0, 1.0, 0.1 ), 4.0 * sf ); let cx = btn_x + btn_size / 2.0; let cy = btn_y + btn_size / 2.0; let arm = btn_size * 0.25; canvas.draw_line( cx - arm, cy - arm, cx + arm, cy + arm, Color::WHITE, 2.0 * sf ); canvas.draw_line( cx + arm, cy - arm, cx - arm, cy + arm, Color::WHITE, 2.0 * sf ); canvas.draw_line( 0.0, tb_h - 0.5, pw as f32, tb_h - 0.5, Color::rgba( 1.0, 1.0, 1.0, 0.15 ), 1.0 ); close_rect_phys } /// Stamp a red warning banner at the top of the surface when the /// active theme came from the in-memory B/W fallback (i.e. /// [`crate::theme::is_fallback_active`] returned `true`). Gives the /// user a clear visual cue that `ltk-theme-default` is missing. /// /// Paints a thin red strip across the top of the surface (24 px /// logical) with the install-instruction text. When the fallback is /// not active this is a no-op. pub( crate ) fn draw_fallback_banner( canvas: &mut Canvas, pw: u32, sf: f32, ) { if !crate::theme::is_fallback_active() { return; } let banner_h = 24.0 * sf; let rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: banner_h }; canvas.fill_rect( rect, Color::rgb( 0.75, 0.10, 0.10 ), 0.0 ); let msg = "ltk: fallback theme — install `ltk-theme-default`"; let size = 12.0; let msg_w = canvas.measure_text( msg, size ); let x = ( ( pw as f32 - msg_w ) / 2.0 ).max( 6.0 * sf ); let y = ( banner_h - size * sf ) / 2.0; canvas.draw_text( msg, x, y, size, Color::WHITE ); } /// Apply or clear the surface's input region (set by /// `App::input_region`). `None` restores the default of "receive /// input everywhere"; `Some(&[])` makes the surface pointer-through. pub( crate ) fn apply_input_region( wl_surface: &WlSurface, compositor: &CompositorState, input_region: Option<&[Rect]>, scale: u32, ) { if let Some( regions ) = input_region { if let Ok( region ) = Region::new( compositor ) { // `Rect`s are physical (layout space); `wl_region` is logical. let s = scale.max( 1 ) as f32; for r in regions { region.add( ( r.x / s ).round() as i32, ( r.y / s ).round() as i32, ( r.width / s ).round() as i32, ( r.height / s ).round() as i32, ); } wl_surface.set_input_region( Some( region.wl_region() ) ); } } else { wl_surface.set_input_region( None ); } }