feat(engine): duck the glass tab bar during card drags
While a drag is committed the floating bar slides below the screen edge (UiTransform translation - no layout dirtying, no fight with the safe-area anchor over Node::bottom) so it never occludes a drop target on the bottom tableau rows; it slides back on release. Same tween rate as the label reveal, snaps under reduce-motion. Desktop docked bar is untouched (no TabBarDuck component). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -603,6 +603,7 @@ impl Plugin for HudPlugin {
|
|||||||
// disjoint from the UiTextFx readout writers (tab labels
|
// disjoint from the UiTextFx readout writers (tab labels
|
||||||
// vs HUD readouts), which Bevy can't prove, so declare it.
|
// vs HUD readouts), which Bevy can't prove, so declare it.
|
||||||
animate_tab_expansion.ambiguous_with(UiTextFx),
|
animate_tab_expansion.ambiguous_with(UiTextFx),
|
||||||
|
duck_tab_bar_during_drag,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(HudButtons)
|
.in_set(HudButtons)
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ use crate::assets::hud_icon_svg::HudIcon;
|
|||||||
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
|
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
|
||||||
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
|
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
|
||||||
use bevy::text::{LineBreak, TextLayout};
|
use bevy::text::{LineBreak, TextLayout};
|
||||||
use bevy::ui::Overflow;
|
use bevy::ui::{Overflow, UiTransform, Val2};
|
||||||
|
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use crate::safe_area::SafeAreaInsets;
|
||||||
|
|
||||||
/// Height of both the pill and the circular Menu button, in logical px.
|
/// Height of both the pill and the circular Menu button, in logical px.
|
||||||
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
|
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
|
||||||
@@ -99,6 +102,20 @@ pub struct TabLabelWrap {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct TabLabel;
|
pub struct TabLabel;
|
||||||
|
|
||||||
|
/// Duck-away state on the bar container: while a card drag is committed
|
||||||
|
/// the whole bar slides below the screen edge so it never occludes a
|
||||||
|
/// drop target on the bottom tableau rows, then slides back on release.
|
||||||
|
/// `progress` ∈ [0, 1]; 1 = fully off-screen.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct TabBarDuck {
|
||||||
|
/// Current slide amount, animated by [`duck_tab_bar_during_drag`].
|
||||||
|
pub progress: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra slide distance past the bar's own height so its drop shadow
|
||||||
|
/// (16 px blur + 6 px offset) also clears the screen edge.
|
||||||
|
const DUCK_SHADOW_ALLOWANCE_PX: f32 = 24.0;
|
||||||
|
|
||||||
/// Rasterised icon textures for the five tab-bar actions.
|
/// Rasterised icon textures for the five tab-bar actions.
|
||||||
pub(super) struct TabIcons {
|
pub(super) struct TabIcons {
|
||||||
undo: Handle<Image>,
|
undo: Handle<Image>,
|
||||||
@@ -179,6 +196,8 @@ pub(super) fn spawn_glass_tab_bar(
|
|||||||
base_bottom: TAB_BAR_MARGIN,
|
base_bottom: TAB_BAR_MARGIN,
|
||||||
},
|
},
|
||||||
HudActionBar,
|
HudActionBar,
|
||||||
|
TabBarDuck { progress: 0.0 },
|
||||||
|
UiTransform::IDENTITY,
|
||||||
))
|
))
|
||||||
.with_children(|bar| {
|
.with_children(|bar| {
|
||||||
// The main pill: Undo · [Draw] · Hint · Pause.
|
// The main pill: Undo · [Draw] · Hint · Pause.
|
||||||
@@ -453,6 +472,46 @@ pub(super) fn animate_tab_expansion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slides the bar off the bottom edge while a card drag is committed and
|
||||||
|
/// back once it ends. Effectively touch-only: the desktop docked bar
|
||||||
|
/// never carries [`TabBarDuck`], so the query is empty there.
|
||||||
|
///
|
||||||
|
/// The slide animates `UiTransform::translation` (a render-time offset,
|
||||||
|
/// +y = down) rather than `Node::bottom`, for two reasons: layout is
|
||||||
|
/// never dirtied mid-drag (the whole point is not to cost frames while
|
||||||
|
/// the player is dragging), and `Node::bottom` is owned by the safe-area
|
||||||
|
/// anchor system — two writers on one field would fight.
|
||||||
|
pub(super) fn duck_tab_bar_during_drag(
|
||||||
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
drag: Option<Res<DragState>>,
|
||||||
|
insets: Option<Res<SafeAreaInsets>>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut bars: Query<(&mut TabBarDuck, &mut UiTransform), With<HudActionBar>>,
|
||||||
|
) {
|
||||||
|
let target = if drag.as_deref().is_some_and(|d| d.committed) {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
// Physical-px inset → logical, same conversion the safe-area anchors
|
||||||
|
// apply (CLAUDE.md §10): the bar sits `inset` above its base position,
|
||||||
|
// so the slide must cover that extra travel to fully clear the edge.
|
||||||
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
|
let inset_logical = insets.as_deref().map_or(0.0, |i| i.bottom / scale);
|
||||||
|
let slide_px = TAB_BAR_CLEARANCE_PX + inset_logical + DUCK_SHADOW_ALLOWANCE_PX;
|
||||||
|
for (mut duck, mut transform) in &mut bars {
|
||||||
|
let next = step_expansion(duck.progress, target, dt, reduce_motion);
|
||||||
|
if next == duck.progress && duck.progress == target {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
duck.progress = next;
|
||||||
|
transform.translation = Val2::px(0.0, next * slide_px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Idle / hover / pressed feedback for glass tab buttons — the glass
|
/// Idle / hover / pressed feedback for glass tab buttons — the glass
|
||||||
/// counterpart of `paint_action_buttons`, using each button's own colour
|
/// counterpart of `paint_action_buttons`, using each button's own colour
|
||||||
/// triple so the accent-filled active pill and the transparent icon
|
/// triple so the accent-filled active pill and the transparent icon
|
||||||
|
|||||||
@@ -1014,6 +1014,54 @@ fn expansion_width_is_linear_in_progress() {
|
|||||||
assert_eq!(expansion_width(4, 16.0, 1.5), full);
|
assert_eq!(expansion_width(4, 16.0, 1.5), full);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// While a committed drag is active the bar must slide down (positive
|
||||||
|
/// `UiTransform` y — off-screen) and return to identity once the drag
|
||||||
|
/// ends. Manual time steps make the tween deterministic.
|
||||||
|
#[test]
|
||||||
|
fn tab_bar_ducks_during_committed_drag_and_returns() {
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use bevy::ui::UiTransform;
|
||||||
|
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
set_manual_time_step(&mut app, 0.05);
|
||||||
|
app.add_systems(Update, duck_tab_bar_during_drag);
|
||||||
|
app.insert_resource(DragState {
|
||||||
|
committed: true,
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let translation_y_px = |app: &mut App| -> f32 {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query_filtered::<&UiTransform, With<HudActionBar>>();
|
||||||
|
let t = q.single(world).expect("bar carries UiTransform");
|
||||||
|
match t.translation.y {
|
||||||
|
Val::Px(px) => px,
|
||||||
|
other => panic!("duck must write Val::Px, got {other:?}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 0.18s slide at 0.05s/frame: fully ducked within 5 frames.
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
let mid = translation_y_px(&mut app);
|
||||||
|
assert!(mid > 0.0, "bar must start sliding down: {mid}");
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
let ducked = translation_y_px(&mut app);
|
||||||
|
assert!(
|
||||||
|
ducked > TAB_BAR_CLEARANCE_PX,
|
||||||
|
"fully ducked bar must clear its own floating footprint: {ducked}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drag ends → bar slides back to identity.
|
||||||
|
app.world_mut().resource_mut::<DragState>().committed = false;
|
||||||
|
for _ in 0..8 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
assert_eq!(translation_y_px(&mut app), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
|
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
|
||||||
/// and snap instantly under reduce-motion.
|
/// and snap instantly under reduce-motion.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user