use bevy::picking::Pickable;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, InteractionDisabled, UiGlobalTransform};
use bevy::window::PrimaryWindow;
use crate::binding::Capturing;
use crate::shortcut::{Modifiers, Shortcut};
use crate::skin::Role;
use crate::text::TextEditing;
use crate::tooltip::Tooltip;
const BADGE_Z: i32 = 160;
#[derive(Clone, Copy, Debug, PartialEq, Reflect)]
#[reflect(Default, Debug, PartialEq, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Reveal {
Modifier(Modifiers),
Key(KeyCode),
Never,
}
impl Default for Reveal {
fn default() -> Self {
Self::Modifier(Modifiers::ALT)
}
}
impl From<Modifiers> for Reveal {
fn from(modifiers: Modifiers) -> Self {
Self::Modifier(modifiers)
}
}
impl Reveal {
fn is_down(self, keys: &ButtonInput<KeyCode>) -> bool {
match self {
Self::Modifier(modifiers) => {
!modifiers.is_empty() && Modifiers::held(keys) == modifiers
}
Self::Key(key) => keys.pressed(key) && Modifiers::held(keys).is_empty(),
Self::Never => false,
}
}
}
#[derive(Resource, Clone, Copy, Debug, PartialEq, Reflect)]
#[reflect(Resource, Default, Debug, PartialEq, Clone)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(default)
)]
pub struct ShortcutBadges {
pub hold: Reveal,
pub delay_seconds: f32,
}
impl Default for ShortcutBadges {
fn default() -> Self {
Self {
hold: Reveal::default(),
delay_seconds: 0.3,
}
}
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Resource, Default, Debug, PartialEq, Clone)]
pub struct BadgesShown(pub bool);
#[derive(Component, Clone, Copy, Debug, Default)]
pub struct ShortcutBadge;
fn badge(label: String) -> impl Bundle {
(
Text::new(label),
TextLayout::new(Justify::Center, LineBreak::NoWrap),
Node {
position_type: PositionType::Absolute,
..default()
},
Role::ShortcutBadge,
ShortcutBadge,
Pickable::IGNORE,
GlobalZIndex(BADGE_Z),
)
}
type Badgeable = (
Or<(With<Shortcut>, With<Tooltip>)>,
Without<InteractionDisabled>,
);
type BadgeItem = (
Entity,
Option<&'static Shortcut>,
Option<&'static Tooltip>,
&'static InheritedVisibility,
&'static ComputedNode,
&'static UiGlobalTransform,
);
#[allow(clippy::too_many_arguments)]
pub fn update_shortcut_badges(
settings: Option<Res<ShortcutBadges>>,
keys: Option<Res<ButtonInput<KeyCode>>>,
time: Option<Res<Time<Real>>>,
mut held_for: Local<f32>,
mut up: Option<ResMut<BadgesShown>>,
editing: Query<(), With<TextEditing>>,
capturing: Query<(), With<Capturing>>,
windows: Query<&Window, With<PrimaryWindow>>,
widgets: Query<BadgeItem, Badgeable>,
badges: Query<(Entity, &ChildOf, &Text), With<ShortcutBadge>>,
mut commands: Commands,
) {
let hold = settings.as_deref().copied().unwrap_or_default();
let blocked =
matches!(hold.hold, Reveal::Key(_)) && (!editing.is_empty() || !capturing.is_empty());
let active = !blocked && keys.as_deref().is_some_and(|keys| hold.hold.is_down(keys));
*held_for = if active {
*held_for + time.as_deref().map_or(0.0, |t| t.delta_secs())
} else {
0.0
};
let now_shown = active && *held_for >= hold.delay_seconds;
if let Some(up) = up.as_deref_mut()
&& up.0 != now_shown
{
up.0 = now_shown;
}
if !now_shown {
for (badge, ..) in &badges {
commands.entity(badge).despawn();
}
return;
}
let Ok(window) = windows.single() else {
return;
};
let screen = Rect::from_corners(Vec2::ZERO, window.physical_size().as_vec2());
let mut wanted: Vec<(Entity, String)> = Vec::new();
for (entity, shortcut, tooltip, shown, node, at) in &widgets {
let Some(shortcut) = shortcut
.copied()
.or_else(|| tooltip.and_then(|t| t.shortcut))
else {
continue;
};
let size = node.size();
if !shown.get() || size.x <= 0.0 || size.y <= 0.0 {
continue;
}
let rect = Rect::from_center_size(at.translation, size);
if rect.intersect(screen).is_empty() {
continue;
}
wanted.push((entity, shortcut.label()));
}
let mut kept: Vec<Entity> = Vec::new();
for (badge, parent, text) in &badges {
let widget = parent.parent();
match wanted.iter().find(|(w, _)| *w == widget) {
Some((_, label)) if !kept.contains(&widget) => {
kept.push(widget);
if text.0 != *label {
commands.entity(badge).insert(Text::new(label.clone()));
}
}
_ => commands.entity(badge).despawn(),
}
}
for (widget, label) in wanted {
if !kept.contains(&widget) {
commands.spawn((badge(label), ChildOf(widget)));
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Reflect)]
#[reflect(Debug, PartialEq, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LegendRow {
pub chord: Shortcut,
pub words: String,
}
impl LegendRow {
pub fn new(chord: Shortcut, words: impl Into<String>) -> Self {
Self {
chord,
words: words.into(),
}
}
}
#[derive(Resource, Clone, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Resource, Default, Debug, PartialEq, Clone)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(default)
)]
pub struct ShortcutLegend {
pub rows: Vec<LegendRow>,
}
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub struct ShortcutLegendCard(pub Vec<LegendRow>);
pub fn update_shortcut_legend(
up: Option<Res<BadgesShown>>,
legend: Option<Res<ShortcutLegend>>,
cards: Query<(Entity, &ShortcutLegendCard)>,
mut commands: Commands,
) {
let wanted: &[LegendRow] = match (&up, &legend) {
(Some(up), Some(legend)) if up.0 => &legend.rows,
_ => &[],
};
if !wanted.is_empty() && cards.iter().any(|(_, card)| card.0 == wanted) {
return;
}
for (card, _) in &cards {
commands.entity(card).despawn();
}
if wanted.is_empty() {
return;
}
commands
.spawn((
Node {
position_type: PositionType::Absolute,
display: Display::Grid,
grid_template_columns: vec![GridTrack::auto(), GridTrack::auto()],
align_items: AlignItems::Center,
left: Val::Percent(50.0),
top: Val::Percent(50.0),
..default()
},
UiTransform {
translation: Val2::percent(-50.0, -50.0),
..default()
},
Role::ShortcutLegend,
ShortcutLegendCard(wanted.to_vec()),
Pickable::IGNORE,
GlobalZIndex(BADGE_Z + 1),
))
.with_children(|card| {
for row in wanted {
card.spawn((
Text::new(row.chord.label()),
TextLayout::new(Justify::Center, LineBreak::NoWrap),
Node {
justify_self: JustifySelf::End,
..default()
},
Role::PromptKey,
Pickable::IGNORE,
));
card.spawn((
Text::new(row.words.clone()),
TextLayout::new(Justify::Left, LineBreak::NoWrap),
Role::Label,
Pickable::IGNORE,
));
}
});
}