srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/decoration/titlebar.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/wayland/src/decoration/titlebar.rs')
-rw-r--r--crates/wayland/src/decoration/titlebar.rs295
1 files changed, 295 insertions, 0 deletions
diff --git a/crates/wayland/src/decoration/titlebar.rs b/crates/wayland/src/decoration/titlebar.rs
new file mode 100644
index 0000000..49f9de5
--- /dev/null
+++ b/crates/wayland/src/decoration/titlebar.rs
@@ -0,0 +1,295 @@
+//! Laying out and rasterizing the whole titlebar band: background, title
+//! text, and the button cluster (which one goes where, which side, how
+//! many). The buttons' own dots/glyphs are `buttons.rs`'s job; the corner
+//! cut at the end is `corners.rs`'s.
+
+use super::buttons::{
+ draw_close_glyph, draw_maximize_glyph, draw_minimize_glyph, draw_zoom_glyph, fill_button_dot, BUTTON_MARGIN, BUTTON_MARGIN_LEFT, TRAFFIC_LIGHT_CLOSE,
+ TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_MAXIMIZE, TRAFFIC_LIGHT_MINIMIZE,
+};
+use super::color::{brighten, darken, rgb_to_bgra};
+use super::corners::round_top_corners;
+use super::font::{blit_glyph, find_system_font, FONT_PIXELS, TEXT_LEFT_PADDING};
+
+/// Renders a `width x height` BGRA8 buffer: filled with `background`, with
+/// `title` drawn left-aligned in `foreground` (best-effort glyph layout --
+/// no text shaping/kerning, adequate for the ASCII-heavy titles window
+/// managers actually display). Returns `None` (caller keeps the previous
+/// solid-color-only look) only if no usable font was found on this system.
+///
+/// `round_corners` should be `false` only for a window whose border strips
+/// are rendered as plain square-cornered fills with no matching rounded
+/// treatment of their own. `border::render_border_top` gives the border's
+/// top strip the same rounded-corner cut (see its own doc comment for how
+/// the two stay visually continuous), so a normal bordered window should
+/// pass `true` here same as a borderless one now - reported live as most
+/// windows (anything with the default border) looking inconsistently
+/// square next to the few borderless ones that were rounded.
+///
+/// `border_width` shifts the corner circle's centre by that many rows (see
+/// `corners::round_top_corners`'s own doc comment): a titlebar with a
+/// border strip sitting above it starts `border_width` rows *into* the
+/// shared circle, not at its top, so it needs the same shift subtracted to
+/// draw its own correct slice of that one circle rather than a second,
+/// uncoordinated one. Pass `0` for an undecorated/borderless window's
+/// titlebar (there is none in practice - an undecorated window has no
+/// titlebar at all - but `0` is also the correct, harmless value if
+/// `round_corners` handling ever changes to allow it).
+#[allow(clippy::too_many_arguments)]
+pub fn render_titlebar(
+ width: u32,
+ height: u32,
+ title: &str,
+ background: (u8, u8, u8),
+ foreground: (u8, u8, u8),
+ round_corners: bool,
+ radius: u32,
+ border_width: u32,
+ focused: bool,
+ // `(button, glyph alpha 0..=255)` - the alpha is the eased hover-
+ // reveal animation's own current progress (see `tick_hover_glyph_
+ // animation`), already discretized by the caller so this stays a
+ // plain data-in function with no `Instant`/timing concept of its own.
+ hovered: Option<(srdwm_core::TitlebarHit, u8)>,
+ centered: bool,
+ buttons_left: bool,
+ // Modern GNOME/Adwaita mode (see `ThemeConfig::button_glyph_always`'s
+ // own doc comment): every glyph drawn at full opacity always, `hovered`
+ // only still used for the background-circle brighten below, not glyph
+ // visibility.
+ glyph_always: bool,
+ // `ThemeConfig::button_order`'s resolved value - see `ButtonOrder`'s
+ // own doc comment. Must stay in agreement with whatever `ResizeEdge::
+ // hit_test` was called with for the same window, the same "renders on
+ // one side, hit-tests on the other" trap `buttons_left` itself already
+ // has to avoid.
+ button_order: Option<srdwm_core::ButtonOrder>,
+ // `ThemeConfig::traffic_light_buttons`'s resolved value - see its own
+ // doc comment for what each mode actually draws differently.
+ traffic_lights: bool,
+ // `Window::is_dialog`'s resolved value - see its own doc comment. Only
+ // Close is ever drawn for a dialog, and never as a coloured traffic
+ // light regardless of `traffic_lights` above (forced off below):
+ // requested directly ("dialog windows shouldn't have maximize/minimize
+ // buttons... don't use traffic lights there ever"). Must stay in exact
+ // agreement with `ResizeEdge::hit_test`'s own `is_dialog` parameter,
+ // the same "renders on one side, hit-tests on the other" trap every
+ // other button-geometry value here already has to avoid.
+ is_dialog: bool,
+) -> Vec<u8> {
+ let (width, height) = (width.max(1) as usize, height.max(1) as usize);
+ // Forced off, not just defaulted - a dialog never gets coloured
+ // traffic lights even when the active theme otherwise uses them
+ // everywhere else.
+ let traffic_lights = traffic_lights && !is_dialog;
+ let bg = rgb_to_bgra(background, 255);
+ let mut buf = vec![0u8; width * height * 4];
+ for px in buf.chunks_exact_mut(4) {
+ px.copy_from_slice(&bg);
+ }
+
+ // Reserve the button squares (whichever side they're actually on)
+ // before laying out text, so a long title elides under them the same
+ // way it would under real window furniture rather than drawing on top
+ // of it. `text_start`/`text_limit` bound the span text is allowed to
+ // occupy - both edges when `buttons_left` (buttons eat into the left,
+ // not the right), only the far edge otherwise.
+ let pitch = srdwm_core::BUTTON_PITCH as usize;
+ let cluster_margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
+ // A dialog only ever gets one button (Close) - see this function's own
+ // `is_dialog` doc comment.
+ let wanted_buttons = if is_dialog { 1 } else { 3 };
+ let button_count = if width >= cluster_margin + pitch * wanted_buttons { wanted_buttons } else { 0 };
+ // `BUTTON_CLUSTER_MARGIN` included, not just the buttons' own `pitch *
+ // button_count` span - the cluster's own leading gap needs reserving
+ // too, or a long title's text could draw underneath it (or, on the
+ // `buttons_left` side, right through the gap between the titlebar's
+ // real edge and the first button).
+ let reserved = if button_count > 0 { cluster_margin + pitch * button_count } else { 0 };
+ let (text_start, text_limit) = if buttons_left { (reserved as f32, width as f32) } else { (TEXT_LEFT_PADDING, width.saturating_sub(reserved) as f32) };
+
+ if let Some(font) = find_system_font() {
+ let baseline = (height as f32 * 0.72).round();
+ // Rasterized up front, not drawn incrementally in one pass - see
+ // `title_centered`'s own doc comment: centering needs the title's
+ // total advance width known *before* the first pixel is placed,
+ // and reusing these glyphs for the real draw below avoids
+ // rasterizing every character twice just to get there. Same
+ // truncation rule as before this existed: a glyph whose own
+ // advance crosses `text_limit` still gets drawn (matches a real
+ // window's furniture starting exactly at `text_limit`, not one
+ // glyph-width short of it), only the *next* one is dropped.
+ let mut glyphs: Vec<(fontdue::Metrics, Vec<u8>)> = Vec::new();
+ let mut total_width = 0.0f32;
+ for ch in title.chars() {
+ if ch.is_control() {
+ continue;
+ }
+ let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS);
+ let advance = metrics.advance_width;
+ let already_past_limit = text_start + total_width >= text_limit;
+ if already_past_limit {
+ break;
+ }
+ total_width += advance;
+ glyphs.push((metrics, coverage));
+ }
+ // Centered on the *whole* titlebar width, not on the narrower
+ // `text_start..text_limit` span left over after reserving the
+ // button squares - matches real macOS, which ignores its own
+ // traffic-light cluster for centering purposes rather than
+ // centering in the remaining space. Centering in the reserved
+ // span instead (the previous behaviour) put the text visibly off
+ // the window's true center - for a 3-button, 30px-tall titlebar
+ // that's a 90px reservation, shifting the centered point 45px
+ // off true center, exactly the "not real center" a user would
+ // notice at a glance. Still clamped into `text_start..text_limit`
+ // afterward so a long title never draws under the buttons.
+ let start_x = if centered { ((width as f32 - total_width) / 2.0).max(text_start).min((text_limit - total_width).max(text_start)) } else { text_start };
+ let mut pen_x = start_x;
+ for (metrics, coverage) in &glyphs {
+ if metrics.width > 0 && metrics.height > 0 {
+ let glyph_x = pen_x + metrics.xmin as f32;
+ let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32;
+ blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, metrics, coverage, background, foreground);
+ }
+ pen_x += metrics.advance_width;
+ }
+ }
+
+ if button_count > 0 {
+ let (mut close_c, mut minimize_c, mut maximize_c) = if !traffic_lights {
+ // Unused in this mode (no dot is ever filled at rest - see the
+ // `traffic_lights` branch below), except as the base colour
+ // `brighten` starts from for the neutral hover backdrop.
+ (background, background, background)
+ } else if focused {
+ (TRAFFIC_LIGHT_CLOSE, TRAFFIC_LIGHT_MINIMIZE, TRAFFIC_LIGHT_MAXIMIZE)
+ } else {
+ (TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_INACTIVE, TRAFFIC_LIGHT_INACTIVE)
+ };
+ // Explicitly requested background-highlight-on-hover for the
+ // titlebar buttons (see docs/TODO.md) - brightens whichever one
+ // is actually hovered, close included, rather than giving close a
+ // separate hardcoded hover colour: close is already red at rest
+ // (focused) or grey (unfocused), same as the other two, so
+ // "red-on-hover for close" falls out of this same brightening,
+ // not a special case. Brightened as soon as a hover is in
+ // progress at all (any glyph alpha > 0), not gated on it having
+ // finished animating in - the circle brightening and the glyph
+ // reveal read as one combined "waking up" motion when they start
+ // together, not two separately-timed effects.
+ //
+ // One button at a time, not the whole cluster - a group-hover
+ // version (matching real macOS's own behaviour) was tried in this
+ // same session and explicitly reverted: the user confirmed this
+ // project's own convention is per-button, not per-cluster, despite
+ // what real macOS itself does.
+ let (mut close_glyph, mut minimize_glyph, mut maximize_glyph) = (0u8, 0u8, 0u8);
+ match hovered {
+ Some((srdwm_core::TitlebarHit::Close, a)) => {
+ close_c = brighten(close_c);
+ close_glyph = a;
+ }
+ Some((srdwm_core::TitlebarHit::Minimize, a)) => {
+ minimize_c = brighten(minimize_c);
+ minimize_glyph = a;
+ }
+ Some((srdwm_core::TitlebarHit::Maximize, a)) => {
+ maximize_c = brighten(maximize_c);
+ maximize_glyph = a;
+ }
+ _ => {}
+ }
+ // Traditional (non-traffic-light) glyphs are always visible, same
+ // as a real Windows/GNOME titlebar's own icons - there's no
+ // filled dot drawing attention to the button at rest the way a
+ // traffic light does, so hiding the glyph too, pending an explicit
+ // `button_glyph = "always"`, would leave the button showing
+ // nothing at all until hovered.
+ let glyph_always = glyph_always || !traffic_lights;
+ if glyph_always {
+ close_glyph = 255;
+ minimize_glyph = 255;
+ maximize_glyph = 255;
+ }
+ // A traffic-light glyph is `darken`ed from that *same* button's own
+ // (possibly already-`brighten`ed-by-hover) colour - real macOS
+ // draws a dark red mark on the red button, dark amber on the
+ // yellow one, not one shared tint reused across all three (see
+ // `darken`'s own doc comment). A traditional glyph instead uses
+ // the titlebar's actual text colour, drawn straight on the
+ // titlebar's own dark background - a dark-on-dark glyph the
+ // traffic-light shade uses would be unreadable there.
+ let (close_shade, minimize_shade, maximize_shade) =
+ if traffic_lights { (darken(close_c), darken(minimize_c), darken(maximize_c)) } else { (foreground, foreground, foreground) };
+ let margin = if buttons_left { BUTTON_MARGIN_LEFT } else { BUTTON_MARGIN };
+ // Closest-to-the-aligned-edge first - must stay in exact
+ // agreement with `ResizeEdge::hit_test`'s own resolution of the
+ // same two fields, the same "renders on one side, hit-tests on
+ // the other" trap `buttons_left` alone already has to avoid. See
+ // `ButtonOrder`'s own doc comment for why the two built-in
+ // defaults are genuinely different relative orderings, not
+ // mirrors of each other.
+ // A dialog always draws Close, full stop - not just whichever
+ // button a `button_order` override would otherwise put first, or
+ // Minimize/Maximize could still end up the one (and only) button
+ // drawn. `button_count` (1 for a dialog) caps the loop below to
+ // just this first slot either way.
+ let order: srdwm_core::ButtonOrder = if is_dialog {
+ [srdwm_core::TitlebarButton::Close; 3]
+ } else {
+ button_order.unwrap_or(if buttons_left {
+ [srdwm_core::TitlebarButton::Close, srdwm_core::TitlebarButton::Minimize, srdwm_core::TitlebarButton::Maximize]
+ } else {
+ [srdwm_core::TitlebarButton::Close, srdwm_core::TitlebarButton::Maximize, srdwm_core::TitlebarButton::Minimize]
+ })
+ };
+ // `BUTTON_CLUSTER_MARGIN` first, then each button's own `pitch * i`
+ // spacing after it - must stay in agreement with `ResizeEdge::
+ // hit_test`'s matching `left`/`right` base, the same "renders on
+ // one side, hit-tests on the other" trap every other button-
+ // geometry value here already has to avoid.
+ for (i, button) in order.iter().take(button_count).enumerate() {
+ let offset = srdwm_core::BUTTON_CLUSTER_MARGIN as usize + pitch * i;
+ match button {
+ srdwm_core::TitlebarButton::Close => {
+ // Traditional mode has no dot at rest - only once this
+ // button is actually the hovered one (`close_glyph > 0`,
+ // the same signal the glyph reveal itself already uses)
+ // does the neutral, brightened backdrop appear at all.
+ // A traffic light always fills, rest state included.
+ if traffic_lights || close_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, close_c);
+ }
+ draw_close_glyph(&mut buf, width, height, offset, buttons_left, margin, close_glyph, close_shade);
+ }
+ srdwm_core::TitlebarButton::Minimize => {
+ if traffic_lights || minimize_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, minimize_c);
+ }
+ draw_minimize_glyph(&mut buf, width, height, offset, buttons_left, margin, minimize_glyph, minimize_shade);
+ }
+ srdwm_core::TitlebarButton::Maximize => {
+ if traffic_lights || maximize_glyph > 0 {
+ fill_button_dot(&mut buf, width, height, offset, buttons_left, margin, maximize_c);
+ }
+ // The macOS "zoom" double-arrow only reads correctly
+ // paired with that same convention's traffic lights --
+ // traditional mode keeps the plain square every other
+ // desktop's own maximize icon already uses.
+ if traffic_lights {
+ draw_zoom_glyph(&mut buf, width, height, offset, buttons_left, margin, maximize_glyph, maximize_shade);
+ } else {
+ draw_maximize_glyph(&mut buf, width, height, offset, buttons_left, margin, maximize_glyph, maximize_shade);
+ }
+ }
+ }
+ }
+ }
+ if round_corners {
+ round_top_corners(&mut buf, width, height, radius, radius as i32 - border_width as i32);
+ }
+ buf
+}
+