fa54c58bc4
Touch layout (USE_TOUCH_UI_LAYOUT) restructures the bottom action bar to five buttons: an enlarged Undo / Draw / Hint trio (96x64px targets, 1.35x labels) between compact Menu and Pause. Draw is new — it fires the same DrawRequestEvent as tapping the stock, so the most frequent action no longer needs a reach to the top of a tall folded screen. Help, Modes, and New Game leave the touch bar (they live in Menu -> System, the Home grid, and Home's hero respectively). Desktop keeps the seven-button bar unchanged (decision 5: touch-only). Holding Undo now steps back repeatedly after a 0.45s delay (5.5/s), each step through the normal request path so the scoring penalty applies. New self-ambiguous DrawRequestWriters set keeps the ambiguity gate at zero with the fourth DrawRequestEvent writer. 6 new hud_plugin tests; workspace suite + clippy green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
678 lines
24 KiB
Rust
678 lines
24 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>,
|
||
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()
|
||
};
|
||
|
||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||
// Android reports it (frames 1-3); 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| {
|
||
if USE_TOUCH_UI_LAYOUT {
|
||
spawn_touch_action_bar(row, &font);
|
||
} else {
|
||
spawn_desktop_action_bar(row, &font);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Phase F touch action bar — five buttons, three of them big.
|
||
///
|
||
/// The core gameplay trio (**Undo · Draw · Hint**) gets enlarged
|
||
/// thumb-reach targets; Menu and Pause stay compact at the edges. The
|
||
/// utility actions the desktop bar carries are reachable elsewhere on
|
||
/// touch and are deliberately absent here: Help lives in Menu → System,
|
||
/// mode switching lives on the Home screen (Phase B), and New Game is
|
||
/// Home's hero button. Draw duplicates the stock-pile tap so the most
|
||
/// frequent action of all no longer requires reaching the top half of a
|
||
/// tall folded screen.
|
||
pub(super) fn spawn_touch_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||
// so Tab (external keyboard) cycles the bar in visual reading order.
|
||
spawn_action_button(
|
||
row,
|
||
MenuButton,
|
||
"Menu",
|
||
None,
|
||
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||
font,
|
||
0,
|
||
TEXT_SECONDARY,
|
||
);
|
||
spawn_primary_action_button(
|
||
row,
|
||
UndoButton,
|
||
"Undo",
|
||
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||
font,
|
||
1,
|
||
);
|
||
spawn_primary_action_button(
|
||
row,
|
||
DrawButton,
|
||
"Draw",
|
||
"Draw from the stock — same as tapping the deck.",
|
||
font,
|
||
2,
|
||
);
|
||
spawn_primary_action_button(
|
||
row,
|
||
HintButton,
|
||
TOUCH_HINT_LABEL,
|
||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||
font,
|
||
3,
|
||
);
|
||
spawn_action_button(
|
||
row,
|
||
PauseButton,
|
||
"Pause",
|
||
None,
|
||
"Pause the game and freeze the timer.",
|
||
font,
|
||
4,
|
||
TEXT_SECONDARY,
|
||
);
|
||
}
|
||
|
||
/// 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,
|
||
);
|
||
}
|
||
|
||
/// Enlarged variant for the touch bar's core gameplay trio (Phase F):
|
||
/// bigger padding / minimum target and a scaled-up label so Undo, Draw,
|
||
/// and Hint read (and hit) as the primary actions. Hotkey chips are
|
||
/// irrelevant on touch, so the variant takes none.
|
||
pub(super) fn spawn_primary_action_button<M: Component>(
|
||
row: &mut ChildSpawnerCommands,
|
||
marker: M,
|
||
label: &str,
|
||
tooltip: &'static str,
|
||
font: &TextFont,
|
||
order: i32,
|
||
) {
|
||
let (pad, min_w, min_h) = primary_action_button_metrics();
|
||
let primary_font = TextFont {
|
||
font: font.font.clone(),
|
||
// 1.35× the bar's responsive size, capped so landscape tablets
|
||
// don't blow the row height out.
|
||
font_size: (font.font_size * 1.35).min(24.0),
|
||
..default()
|
||
};
|
||
spawn_action_button_sized(
|
||
row,
|
||
marker,
|
||
label,
|
||
None,
|
||
tooltip,
|
||
&primary_font,
|
||
order,
|
||
TEXT_PRIMARY,
|
||
pad,
|
||
min_w,
|
||
min_h,
|
||
);
|
||
}
|
||
|
||
/// Shared body of [`spawn_action_button`] / [`spawn_primary_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)));
|
||
}
|
||
});
|
||
}
|