c8fc3e7a09
Touch layout only (desktop bar unchanged): a floating glass pill (Undo / Draw / Hint / Pause) plus a detached circular Menu button, margin above the bottom edge on top of the safe-area inset. Draw is the persistently expanded accent pill (icon + label); other buttons are icon-only and slide their label out while pressed, snapping under reduce-motion. Buttons keep the existing marker components so click handlers, tooltips, focus ring, and chrome toggle work unchanged. Icons: None degrades to text-fallback labels (MinimalPlugins tests / SVG regression), keeping the bar fully usable. Toast stack clearance now derives from TAB_BAR_CLEARANCE_PX; the legacy touch bar spawn path and primary-button metrics are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
128 lines
4.8 KiB
Rust
128 lines
4.8 KiB
Rust
//! SVG builders for the floating tab bar's action icons.
|
||
//!
|
||
//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
|
||
//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
|
||
//! is missing on Android entirely), so the icons are authored here as tiny
|
||
//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
|
||
//! as the card themes (`assets::rasterize_svg`).
|
||
//!
|
||
//! Icons are drawn in **white** on a transparent background inside a
|
||
//! 24-unit logical box; the spawn site tints them to the theme colour via
|
||
//! `ImageNode::color` (a per-node multiply), so one texture serves every
|
||
//! button state.
|
||
|
||
use bevy::image::Image;
|
||
use bevy::math::UVec2;
|
||
|
||
use super::svg_loader::{SvgLoaderError, rasterize_svg};
|
||
|
||
/// Rasterisation size for one tab-bar icon texture. Icons display at
|
||
/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
|
||
/// factor (and any future 3× phone) while staying trivially small.
|
||
pub const HUD_ICON_TEXTURE_PX: u32 = 96;
|
||
|
||
/// The five actions the floating touch tab bar can show.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum HudIcon {
|
||
/// Counter-clockwise "take back" arrow.
|
||
Undo,
|
||
/// Two offset card outlines — draw from the stock.
|
||
Draw,
|
||
/// Lightbulb — highlight a suggested move.
|
||
Hint,
|
||
/// Two vertical bars.
|
||
Pause,
|
||
/// Three-line hamburger — the floating menu button.
|
||
Menu,
|
||
}
|
||
|
||
impl HudIcon {
|
||
/// Build the icon's SVG document. All icons share one visual language:
|
||
/// 2-unit white strokes, round caps, transparent fill, 24-unit box.
|
||
pub fn svg(self) -> String {
|
||
let body = match self {
|
||
// Arrow head pointing left into a half-circle sweeping back
|
||
// over the top — the conventional undo mark.
|
||
HudIcon::Undo => concat!(
|
||
r##"<path d="M 8 5 L 4 9 L 8 13"/>"##,
|
||
r##"<path d="M 4 9 H 14 A 5.5 5.5 0 0 1 14 20 H 8"/>"##,
|
||
),
|
||
// Back card peeks out behind the front card; the front card is
|
||
// drawn second so its stroke reads on top.
|
||
HudIcon::Draw => concat!(
|
||
r##"<rect x="8.5" y="3" width="11" height="15" rx="2"/>"##,
|
||
r##"<rect x="4" y="6.5" width="11" height="15" rx="2"/>"##,
|
||
),
|
||
// Bulb dome with a flat lamp base beneath.
|
||
HudIcon::Hint => concat!(
|
||
r##"<path d="M 9 15.5 A 6.5 6.5 0 1 1 15 15.5 V 18 H 9 Z"/>"##,
|
||
r##"<path d="M 10 21 H 14"/>"##,
|
||
),
|
||
HudIcon::Pause => concat!(
|
||
r##"<path d="M 9 6 V 18"/>"##,
|
||
r##"<path d="M 15 6 V 18"/>"##,
|
||
),
|
||
HudIcon::Menu => concat!(
|
||
r##"<path d="M 4 6.5 H 20"/>"##,
|
||
r##"<path d="M 4 12 H 20"/>"##,
|
||
r##"<path d="M 4 17.5 H 20"/>"##,
|
||
),
|
||
};
|
||
format!(
|
||
concat!(
|
||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">"##,
|
||
r##"<g fill="none" stroke="#ffffff" stroke-width="2" "##,
|
||
r##"stroke-linecap="round" stroke-linejoin="round">{body}</g>"##,
|
||
r##"</svg>"##,
|
||
),
|
||
body = body
|
||
)
|
||
}
|
||
|
||
/// Rasterise this icon into a Bevy [`Image`] at
|
||
/// [`HUD_ICON_TEXTURE_PX`] square.
|
||
pub fn rasterize(self) -> Result<Image, SvgLoaderError> {
|
||
rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const ALL: [HudIcon; 5] = [
|
||
HudIcon::Undo,
|
||
HudIcon::Draw,
|
||
HudIcon::Hint,
|
||
HudIcon::Pause,
|
||
HudIcon::Menu,
|
||
];
|
||
|
||
/// Every icon must survive the real usvg/resvg pipeline at the real
|
||
/// texture size — a malformed path here would otherwise only surface
|
||
/// as a blank button on device.
|
||
#[test]
|
||
fn every_icon_rasterizes_at_texture_size() {
|
||
for icon in ALL {
|
||
let img = icon.rasterize().expect("icon SVG must rasterise");
|
||
assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
|
||
assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
|
||
}
|
||
}
|
||
|
||
/// Rasterised icons must contain visible (non-transparent) pixels —
|
||
/// guards against a stroke accidentally drawn outside the viewBox.
|
||
#[test]
|
||
fn every_icon_has_visible_pixels() {
|
||
for icon in ALL {
|
||
let img = icon.rasterize().expect("icon SVG must rasterise");
|
||
let data = img.data.as_ref().expect("rasterised image has data");
|
||
let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
|
||
assert!(
|
||
opaque > 100,
|
||
"{icon:?} renders only {opaque} visible pixels"
|
||
);
|
||
}
|
||
}
|
||
}
|