diff options
| author | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
| commit | 0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch) | |
| tree | 7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/decoration/titlebar.rs | |
| parent | 413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff) | |
| download | srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip | |
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has
diverged with its own separate fixes today. Nothing here is reviewed
or curated yet - this exists purely so none of this work can be lost
to a git operation, disk issue, or worktree cleanup while that
reconciliation happens.
Diffstat (limited to 'crates/wayland/src/decoration/titlebar.rs')
| -rw-r--r-- | crates/wayland/src/decoration/titlebar.rs | 295 |
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 +} + |