fix(ui): add modal guard to profile, make modal dimensions responsive (#57, #67)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-27 19:21:09 -07:00
parent cfdf27c8c7
commit 35fde160fa
2 changed files with 142 additions and 58 deletions
+87 -43
View File
@@ -58,11 +58,11 @@ use crate::font_plugin::FontResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE,
HighContrastBorder, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, VAL_SPACE_5,
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
scaled_duration,
};
// ---------------------------------------------------------------------------
@@ -193,7 +193,10 @@ where
.spawn((
plugin_marker,
ModalScrim,
ModalEntering { elapsed: 0.0, duration },
ModalEntering {
elapsed: 0.0,
duration,
},
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
@@ -227,11 +230,11 @@ where
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_4,
width: Val::Percent(90.0),
padding: UiRect::all(VAL_SPACE_5),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
max_width: Val::Px(720.0),
min_width: Val::Px(360.0),
align_items: AlignItems::Stretch,
..default()
},
@@ -295,12 +298,7 @@ pub fn spawn_modal_body_text(
font_size: TYPE_BODY_LG,
..default()
};
parent.spawn((
ModalBody,
Text::new(text.into()),
font,
TextColor(color),
));
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
}
/// Spawns the bottom actions row — flex-row with primary right-aligned.
@@ -343,7 +341,11 @@ pub fn spawn_modal_button<M: Component>(
variant: ButtonVariant,
font_res: Option<&FontResource>,
) {
let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None };
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
hotkey
} else {
None
};
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont {
font: font_handle.clone(),
@@ -517,7 +519,10 @@ pub fn apply_modal_enter_speed(
pub fn advance_modal_enter(
time: Res<Time>,
mut commands: Commands,
mut scrims: Query<(Entity, &mut ModalEntering, &mut BackgroundColor, &Children), With<ModalScrim>>,
mut scrims: Query<
(Entity, &mut ModalEntering, &mut BackgroundColor, &Children),
With<ModalScrim>,
>,
mut cards: Query<&mut Transform, With<ModalCard>>,
) {
let dt = time.delta_secs();
@@ -653,10 +658,7 @@ pub fn dismiss_modal_on_scrim_click(
/// paint system.
#[allow(clippy::type_complexity)]
pub fn paint_modal_buttons(
mut buttons: Query<
(&Interaction, &ModalButton, &mut BackgroundColor),
Changed<Interaction>,
>,
mut buttons: Query<(&Interaction, &ModalButton, &mut BackgroundColor), Changed<Interaction>>,
) {
for (interaction, modal_button, mut bg) in &mut buttons {
bg.0 = match interaction {
@@ -687,7 +689,12 @@ impl Plugin for UiModalPlugin {
// before advance computes `t`.
app.add_systems(
Update,
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(),
(
apply_modal_enter_speed,
advance_modal_enter,
paint_modal_buttons,
)
.chain(),
);
// Click-outside-to-dismiss is independent of the open
// animation chain — it reads `just_pressed(Left)` and runs
@@ -774,6 +781,11 @@ mod tests {
(card_scale - MODAL_ENTER_START_SCALE).abs() < 1e-6,
"card should spawn at MODAL_ENTER_START_SCALE; got {card_scale}"
);
let card_node = card_node_of(&app, scrim);
assert_eq!(card_node.width, Val::Percent(90.0));
assert_eq!(card_node.max_width, Val::Px(720.0));
assert_eq!(card_node.min_width, Val::Auto);
}
/// After enough simulated ticks for `elapsed >= duration`, the
@@ -817,23 +829,42 @@ mod tests {
);
}
/// Returns the `Entity` of the first `ModalCard` child of the given
/// scrim.
fn card_entity_of(world: &World, scrim: Entity) -> Entity {
let children = world
.entity(scrim)
.get::<Children>()
.expect("scrim should have a card child");
children
.iter()
.find(|child| world.entity(*child).get::<ModalCard>().is_some())
.expect("scrim should have a ModalCard child")
}
/// Returns the `Node` of the first `ModalCard` child of the given
/// scrim.
fn card_node_of(app: &App, scrim: Entity) -> &Node {
let world = app.world();
let card = card_entity_of(world, scrim);
world
.entity(card)
.get::<Node>()
.expect("ModalCard child should have a Node")
}
/// Returns the X-component of the first `ModalCard` child of the
/// given scrim's `Transform::scale`. All three components are kept
/// in sync by the system so reading X is sufficient.
fn card_scale_of(app: &mut App, scrim: Entity) -> f32 {
let world = app.world();
let children = world
.entity(scrim)
.get::<Children>()
.expect("scrim should have a card child");
for child in children.iter() {
if let Some(t) = world.entity(child).get::<Transform>()
&& world.entity(child).get::<ModalCard>().is_some()
{
return t.scale.x;
}
}
panic!("no ModalCard child with a Transform under scrim {scrim:?}");
let card = card_entity_of(world, scrim);
world
.entity(card)
.get::<Transform>()
.expect("ModalCard child should have a Transform")
.scale
.x
}
/// Tells `TimePlugin` to advance the clock by `secs` on the next
@@ -844,9 +875,9 @@ mod tests {
fn set_manual_time_step(app: &mut App, secs: f32) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
secs,
)));
}
// -----------------------------------------------------------------------
@@ -871,10 +902,26 @@ mod tests {
fn cursor_is_inside_rect_outside_returns_false() {
let centre = Vec2::new(200.0, 150.0);
let size = Vec2::new(100.0, 60.0);
assert!(!cursor_is_inside_rect(Vec2::new(149.0, 150.0), centre, size)); // left
assert!(!cursor_is_inside_rect(Vec2::new(251.0, 150.0), centre, size)); // right
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 119.0), centre, size)); // above
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 181.0), centre, size)); // below
assert!(!cursor_is_inside_rect(
Vec2::new(149.0, 150.0),
centre,
size
)); // left
assert!(!cursor_is_inside_rect(
Vec2::new(251.0, 150.0),
centre,
size
)); // right
assert!(!cursor_is_inside_rect(
Vec2::new(200.0, 119.0),
centre,
size
)); // above
assert!(!cursor_is_inside_rect(
Vec2::new(200.0, 181.0),
centre,
size
)); // below
}
/// Builds a headless app capable of running
@@ -966,9 +1013,7 @@ mod tests {
/// `MinimalPlugins` doesn't run the input clear pass, so we mark
/// the clear by hand on the resource between presses.
fn press_left_mouse(app: &mut App) {
let mut input = app
.world_mut()
.resource_mut::<ButtonInput<MouseButton>>();
let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
input.clear();
input.press(MouseButton::Left);
}
@@ -1068,4 +1113,3 @@ mod tests {
);
}
}