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>
590 lines
21 KiB
Rust
590 lines
21 KiB
Rust
//! HUD construction: band, columns, avatar, and action-bar spawning.
|
|
|
|
use super::*;
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use crate::avatar_plugin::AvatarResource;
|
|
|
|
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
|
/// the screen so the card layout (computed by `layout::compute_layout` using
|
|
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
|
///
|
|
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
|
/// A slim grey background is handled by each content section individually
|
|
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
|
pub(super) fn spawn_hud_band(mut commands: Commands) {
|
|
const BASE_TOP: f32 = 0.0;
|
|
commands.spawn((
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
top: Val::Px(BASE_TOP),
|
|
left: Val::Px(0.0),
|
|
width: Val::Percent(100.0),
|
|
height: Val::Px(HUD_BAND_HEIGHT),
|
|
..default()
|
|
},
|
|
ZIndex(Z_HUD - 1),
|
|
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
|
HudBand,
|
|
));
|
|
}
|
|
|
|
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
|
/// top-left of the play area.
|
|
///
|
|
/// Tiers (top to bottom):
|
|
/// 1. **Primary** — Score (display weight) · Moves · Timer.
|
|
/// Always visible during gameplay.
|
|
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
|
|
/// Draw-cycle indicator. Each cell is empty when not relevant; the
|
|
/// row collapses visually when all cells are empty.
|
|
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
|
|
/// Both penalty counters share `STATE_WARNING` (the audit found
|
|
/// they were inconsistent: Undos amber, Recycles white).
|
|
/// 4. **Selection** — keyboard-driven pile selector chip.
|
|
///
|
|
/// The audit identified the original single-row layout (10 readouts in
|
|
/// one horizontal flex row, 5+ colour families competing) as the
|
|
/// player's #1 complaint. This restructure groups by purpose, lets
|
|
/// transient items disappear cleanly, and uses the typography scale to
|
|
/// make Score the visual protagonist.
|
|
pub(super) fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
|
let font_score = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_HEADLINE,
|
|
..default()
|
|
};
|
|
let font_lg = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_BODY_LG,
|
|
..default()
|
|
};
|
|
let font_body = TextFont {
|
|
font: font_handle,
|
|
font_size: TYPE_BODY,
|
|
..default()
|
|
};
|
|
|
|
let row_node = || Node {
|
|
flex_direction: FlexDirection::Row,
|
|
column_gap: VAL_SPACE_3,
|
|
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
|
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
|
// Auto-complete, selection chip) can collectively be wider than
|
|
// the available space and overflow into the action-button column
|
|
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
|
// a second line; on a desktop window the rows stay single-line
|
|
// because the parent column has no width cap and the row never
|
|
// exceeds the natural line width.
|
|
flex_wrap: FlexWrap::Wrap,
|
|
row_gap: VAL_SPACE_1,
|
|
align_items: AlignItems::Baseline,
|
|
..default()
|
|
};
|
|
|
|
commands
|
|
.spawn((
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
left: VAL_SPACE_3,
|
|
top: Val::Px(SPACE_2),
|
|
flex_direction: FlexDirection::Column,
|
|
// Cap the column at 50% of viewport so on narrow
|
|
// (mobile) widths the inner tier rows have a bounded
|
|
// width to wrap against, and the column can't bleed
|
|
// into the right-anchored action button row (also
|
|
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
|
// wider than any tier row's natural width, so the
|
|
// visible layout is unaffected.
|
|
max_width: Val::Percent(50.0),
|
|
row_gap: VAL_SPACE_1,
|
|
..default()
|
|
},
|
|
ZIndex(Z_HUD),
|
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
|
HudColumn,
|
|
))
|
|
.with_children(|hud| {
|
|
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
|
// Moves and Timer are supporting context (BODY_LG, secondary tone).
|
|
hud.spawn(row_node()).with_children(|t1| {
|
|
t1.spawn((
|
|
HudScore,
|
|
Tooltip::new("Points earned this game. Hidden in Zen mode."),
|
|
Text::new("Score: 0"),
|
|
font_score.clone(),
|
|
TextColor(TEXT_PRIMARY),
|
|
));
|
|
t1.spawn((
|
|
HudMoves,
|
|
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
|
|
Text::new("Moves: 0"),
|
|
font_lg.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
t1.spawn((
|
|
HudTime,
|
|
Tooltip::new("Time on this game. Counts down in Time Attack."),
|
|
Text::new("0:00"),
|
|
font_lg.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
});
|
|
|
|
// Tier 2 — mode context. Each cell is empty until update_hud
|
|
// populates it (and clears it when no longer relevant), so the
|
|
// row collapses when nothing in this tier applies.
|
|
hud.spawn(row_node()).with_children(|t2| {
|
|
t2.spawn((
|
|
HudMode,
|
|
Tooltip::new("Active game mode. Click Modes to switch."),
|
|
Text::new(""),
|
|
font_body.clone(),
|
|
TextColor(ACCENT_PRIMARY),
|
|
));
|
|
t2.spawn((
|
|
HudChallenge,
|
|
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
|
|
Text::new(""),
|
|
font_body.clone(),
|
|
TextColor(STATE_INFO),
|
|
));
|
|
t2.spawn((
|
|
HudDrawCycle,
|
|
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
|
|
Text::new(""),
|
|
font_body.clone(),
|
|
TextColor(STATE_INFO),
|
|
));
|
|
t2.spawn((
|
|
HudWonPreviously,
|
|
Tooltip::new("You've won this deal before. Same seed in your replay history."),
|
|
Text::new(""),
|
|
font_body.clone(),
|
|
TextColor(STATE_SUCCESS),
|
|
));
|
|
});
|
|
|
|
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
|
// warning hue so they read as the same category ("you took a
|
|
// penalty"); the auto-complete badge stays success-green.
|
|
hud.spawn(row_node()).with_children(|t3| {
|
|
t3.spawn((
|
|
HudUndos,
|
|
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
|
|
Text::new(""),
|
|
font_body.clone(),
|
|
TextColor(STATE_WARNING),
|
|
));
|
|
t3.spawn((
|
|
HudRecycles,
|
|
Tooltip::new(
|
|
"Times you've recycled the stock. Three or more unlocks Comeback.",
|
|
),
|
|
Text::new(""),
|
|
font_body.clone(),
|
|
TextColor(STATE_WARNING),
|
|
));
|
|
t3.spawn((
|
|
HudAutoComplete,
|
|
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
|
|
Text::new(""),
|
|
font_body.clone(),
|
|
TextColor(STATE_SUCCESS),
|
|
));
|
|
});
|
|
|
|
// Tier 4 — selection chip. Stays in HUD for now; a future
|
|
// pass can reposition it next to the selected pile.
|
|
hud.spawn(row_node()).with_children(|t4| {
|
|
t4.spawn((
|
|
HudSelection,
|
|
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
|
|
Text::new(""),
|
|
font_body,
|
|
TextColor(ACCENT_SECONDARY),
|
|
));
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Spawns the circular avatar / initials button anchored to the top-right
|
|
/// of the HUD band. Initial content is seeded from whatever resources are
|
|
/// available at startup; `update_hud_avatar` replaces the children whenever
|
|
/// `AvatarResource` or `SettingsResource` later changes.
|
|
pub(super) fn spawn_hud_avatar(
|
|
font_res: Option<Res<FontResource>>,
|
|
avatar: Option<Res<AvatarResource>>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
mut commands: Commands,
|
|
) {
|
|
const SIZE: f32 = 32.0;
|
|
let id = commands
|
|
.spawn((
|
|
HudAvatar,
|
|
Button,
|
|
Tooltip::new("Your profile — tap to open."),
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
top: Val::Px(SPACE_2),
|
|
right: VAL_SPACE_3,
|
|
width: Val::Px(SIZE),
|
|
height: Val::Px(SIZE),
|
|
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
|
align_items: AlignItems::Center,
|
|
justify_content: JustifyContent::Center,
|
|
..default()
|
|
},
|
|
BackgroundColor(ACCENT_PRIMARY),
|
|
ZIndex(Z_HUD),
|
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
|
))
|
|
.id();
|
|
spawn_avatar_child(
|
|
&mut commands,
|
|
id,
|
|
avatar.as_deref(),
|
|
settings.as_deref(),
|
|
font_res.as_deref(),
|
|
);
|
|
}
|
|
|
|
/// Re-spawns the avatar circle content (image or initials) whenever either
|
|
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
|
|
/// image arriving after download and the username changing after login.
|
|
pub(super) fn update_hud_avatar(
|
|
avatar: Option<Res<AvatarResource>>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
q: Query<Entity, With<HudAvatar>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
|
|
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
|
|
if !avatar_changed && !settings_changed {
|
|
return;
|
|
}
|
|
let Ok(entity) = q.single() else {
|
|
return;
|
|
};
|
|
commands.entity(entity).despawn_related::<Children>();
|
|
spawn_avatar_child(
|
|
&mut commands,
|
|
entity,
|
|
avatar.as_deref(),
|
|
settings.as_deref(),
|
|
font_res.as_deref(),
|
|
);
|
|
}
|
|
|
|
/// Populates the avatar container with either the downloaded image or an
|
|
/// initials fallback disc. Called from both the startup spawn and the
|
|
/// reactive update system so the rendering logic lives in one place.
|
|
pub(super) fn spawn_avatar_child(
|
|
commands: &mut Commands,
|
|
parent: Entity,
|
|
avatar: Option<&AvatarResource>,
|
|
settings: Option<&SettingsResource>,
|
|
font_res: Option<&FontResource>,
|
|
) {
|
|
const SIZE: f32 = 32.0;
|
|
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
|
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
|
commands
|
|
.entity(parent)
|
|
.insert(BackgroundColor(ACCENT_PRIMARY));
|
|
// Image fills the circle container; border_radius clips it to a disc.
|
|
commands.entity(parent).with_children(|b| {
|
|
b.spawn((
|
|
ImageNode::new(handle),
|
|
Node {
|
|
width: Val::Px(SIZE),
|
|
height: Val::Px(SIZE),
|
|
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
|
..default()
|
|
},
|
|
));
|
|
});
|
|
} else {
|
|
let initial = settings
|
|
.and_then(|s| match &s.0.sync_backend {
|
|
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
|
|
SyncBackend::Local => None,
|
|
})
|
|
.and_then(|c| c.to_uppercase().next())
|
|
.unwrap_or('?');
|
|
// Real initial (logged in) keeps the red accent disc; the '?'
|
|
// unauthenticated fallback uses a neutral grey so it reads as a
|
|
// "tap to log in" affordance rather than an error.
|
|
let disc_bg = if initial == '?' {
|
|
BG_ELEVATED_HI
|
|
} else {
|
|
ACCENT_PRIMARY
|
|
};
|
|
commands.entity(parent).insert(BackgroundColor(disc_bg));
|
|
commands.entity(parent).with_children(|b| {
|
|
b.spawn((
|
|
Text::new(initial.to_string()),
|
|
TextFont {
|
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
font_size: 14.0,
|
|
..default()
|
|
},
|
|
TextColor(TEXT_PRIMARY),
|
|
));
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Opens the Profile overlay when the avatar button is pressed.
|
|
pub(super) fn handle_avatar_button(
|
|
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
|
|
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
|
|
) {
|
|
for interaction in &interaction_query {
|
|
if *interaction == Interaction::Pressed {
|
|
toggle_profile.write(ToggleProfileRequestEvent);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawns the action button bar anchored to the top-right of the window.
|
|
/// Each child is a clickable button mirroring a keyboard accelerator —
|
|
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
|
/// are the primary entry point and the hotkeys are optional.
|
|
///
|
|
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
|
/// because it's the most consequential action; the destructive button sits
|
|
/// on its own visual edge.
|
|
pub(super) fn spawn_action_buttons(
|
|
font_res: Option<Res<FontResource>>,
|
|
windows: Query<&Window>,
|
|
images: Option<ResMut<Assets<Image>>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let action_font_size =
|
|
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
|
|
let font = TextFont {
|
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
|
font_size: action_font_size,
|
|
..default()
|
|
};
|
|
|
|
// Touch layout: the floating glass tab bar (pill + circular Menu).
|
|
// `Assets<Image>` is absent under `MinimalPlugins`; `rasterize_into`
|
|
// returning `None` switches the bar to its text-fallback form.
|
|
if USE_TOUCH_UI_LAYOUT {
|
|
let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs));
|
|
spawn_glass_tab_bar(&mut commands, &font, icons.as_ref());
|
|
return;
|
|
}
|
|
|
|
// Desktop bottom bar: full-width, centered, docked to the bottom edge.
|
|
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
|
// the platform reports it; initial value is 0.0.
|
|
commands
|
|
.spawn((
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
bottom: Val::Px(0.0),
|
|
left: Val::Px(0.0),
|
|
width: Val::Percent(100.0),
|
|
flex_direction: FlexDirection::Row,
|
|
flex_wrap: FlexWrap::Wrap,
|
|
justify_content: JustifyContent::Center,
|
|
column_gap: ACTION_BAR_COLUMN_GAP,
|
|
row_gap: VAL_SPACE_2,
|
|
align_items: AlignItems::Center,
|
|
padding: UiRect {
|
|
left: VAL_SPACE_3,
|
|
right: VAL_SPACE_3,
|
|
top: VAL_SPACE_2,
|
|
bottom: VAL_SPACE_2,
|
|
},
|
|
..default()
|
|
},
|
|
BackgroundColor(BG_HUD_BAND),
|
|
ZIndex(Z_HUD),
|
|
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
|
HudActionBar,
|
|
))
|
|
.with_children(|row| {
|
|
spawn_desktop_action_bar(row, &font);
|
|
});
|
|
}
|
|
|
|
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
|
|
/// is touch-only). All seven actions, uniform sizing.
|
|
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
|
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
|
// so Tab cycles the action bar in visual reading order.
|
|
// Undo and Pause are the primary gameplay actions — full brightness.
|
|
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
|
spawn_action_button(
|
|
row,
|
|
MenuButton,
|
|
"Menu \u{2193}",
|
|
None,
|
|
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
|
font,
|
|
0,
|
|
TEXT_SECONDARY,
|
|
);
|
|
spawn_action_button(
|
|
row,
|
|
UndoButton,
|
|
"Undo",
|
|
Some("U"),
|
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
|
font,
|
|
1,
|
|
TEXT_PRIMARY,
|
|
);
|
|
spawn_action_button(
|
|
row,
|
|
PauseButton,
|
|
"Pause",
|
|
Some("Esc"),
|
|
"Pause the game and freeze the timer.",
|
|
font,
|
|
2,
|
|
TEXT_PRIMARY,
|
|
);
|
|
spawn_action_button(
|
|
row,
|
|
HelpButton,
|
|
"Help",
|
|
Some("F1"),
|
|
"Show controls, rules, and keyboard shortcuts.",
|
|
font,
|
|
3,
|
|
TEXT_SECONDARY,
|
|
);
|
|
spawn_action_button(
|
|
row,
|
|
HintButton,
|
|
"Hint",
|
|
Some("H"),
|
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
|
font,
|
|
4,
|
|
TEXT_SECONDARY,
|
|
);
|
|
spawn_action_button(
|
|
row,
|
|
ModesButton,
|
|
"Modes \u{2193}",
|
|
None,
|
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
font,
|
|
5,
|
|
TEXT_SECONDARY,
|
|
);
|
|
spawn_action_button(
|
|
row,
|
|
NewGameButton,
|
|
"New Game",
|
|
Some("N"),
|
|
"Start a fresh deal. Confirms first if a game is in progress.",
|
|
font,
|
|
6,
|
|
TEXT_SECONDARY,
|
|
);
|
|
}
|
|
|
|
/// Spawns a single action button as a child of `row`. Each button shares
|
|
/// the same node geometry, idle colour, and `ActionButton` marker so
|
|
/// `paint_action_buttons` can recolour all of them with one query.
|
|
///
|
|
/// `order` is the button's index inside the action bar (0 for the
|
|
/// leftmost). It propagates into the [`Focusable`] this function inserts
|
|
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
|
|
///
|
|
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
|
/// action button ships with one — there is no opt-out — because each button
|
|
/// represents a player-triggered action and benefits from a one-line
|
|
/// reminder of what it does.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn spawn_action_button<M: Component>(
|
|
row: &mut ChildSpawnerCommands,
|
|
marker: M,
|
|
label: &str,
|
|
hotkey: Option<&'static str>,
|
|
tooltip: &'static str,
|
|
font: &TextFont,
|
|
order: i32,
|
|
text_color: Color,
|
|
) {
|
|
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
|
// touch device — the button itself is the affordance — and they
|
|
// visibly clutter the narrow-viewport action row. The chevrons on
|
|
// Menu/Modes remain because they indicate dropdown behaviour.
|
|
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
|
|
hotkey
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let (pad, min_w, min_h) = action_button_metrics();
|
|
spawn_action_button_sized(
|
|
row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h,
|
|
);
|
|
}
|
|
|
|
/// Shared body of [`spawn_action_button`] — one place owns the component
|
|
/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
|
|
/// bar button identically.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn spawn_action_button_sized<M: Component>(
|
|
row: &mut ChildSpawnerCommands,
|
|
marker: M,
|
|
label: &str,
|
|
hotkey: Option<&'static str>,
|
|
tooltip: &'static str,
|
|
font: &TextFont,
|
|
order: i32,
|
|
text_color: Color,
|
|
pad: UiRect,
|
|
min_w: Val,
|
|
min_h: Val,
|
|
) {
|
|
let hotkey_font = TextFont {
|
|
font: font.font.clone(),
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
};
|
|
row.spawn((
|
|
marker,
|
|
ActionButton,
|
|
Button,
|
|
Tooltip::new(tooltip),
|
|
Focusable {
|
|
group: FocusGroup::Hud,
|
|
order,
|
|
},
|
|
Node {
|
|
padding: pad,
|
|
min_width: min_w,
|
|
min_height: min_h,
|
|
justify_content: JustifyContent::Center,
|
|
align_items: AlignItems::Center,
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
column_gap: VAL_SPACE_2,
|
|
..default()
|
|
},
|
|
BackgroundColor(ACTION_BTN_IDLE),
|
|
BorderColor::all(BORDER_SUBTLE),
|
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
))
|
|
.with_children(|b| {
|
|
spawn_action_button_label(b, label, font, text_color);
|
|
if let Some(key) = hotkey {
|
|
// Hotkey hint rendered as a dim caption next to the label —
|
|
// keeps the keyboard accelerator discoverable without
|
|
// hijacking the button's primary affordance.
|
|
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
|
|
}
|
|
});
|
|
}
|