use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use super::{GamepadState, ps};
use crate::ecs::{Application, Plugin};
use crate::scene::SceneEntity;
use crate::ui::{
Background, Color, PanelMarker, Position, Size, Text, TextLine, Widget, font, layout,
};
const COLUMNS: usize = 30;
const FIXED_LINES: usize = 6;
const HELD_LINES: usize = 3;
const LINES: usize = FIXED_LINES + HELD_LINES;
#[derive(Component, Clone, Copy, Debug)]
pub struct HidDebugLine(pub usize);
pub struct HIDDebugOverlay {
x: f32,
y: f32,
pixel_size: f32,
color: Color,
background: Color,
}
impl Default for HIDDebugOverlay {
fn default() -> Self {
Self {
x: layout::SCREEN_MARGIN,
y: layout::SCREEN_MARGIN,
pixel_size: 2.0,
color: Color::srgb(0.85, 0.88, 0.95),
background: Color::srgba(0.06, 0.06, 0.09, 0.80),
}
}
}
impl HIDDebugOverlay {
pub fn new() -> Self {
Self::default()
}
pub fn at(mut self, x: f32, y: f32) -> Self {
self.x = x;
self.y = y;
self
}
pub fn pixel_size(mut self, pixel_size: f32) -> Self {
self.pixel_size = pixel_size;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn background(mut self, color: Color) -> Self {
self.background = color;
self
}
fn padding(&self) -> f32 {
self.pixel_size * 4.0
}
fn line_height(&self) -> f32 {
font::text_height(self.pixel_size) + self.pixel_size * 3.0
}
}
#[derive(Component, Clone, Debug)]
pub struct HidOverlay {
pub panel: Entity,
pub lines: Vec<Entity>,
}
impl HidOverlay {
pub fn entities(&self) -> impl Iterator<Item = Entity> + '_ {
std::iter::once(self.panel).chain(self.lines.iter().copied())
}
}
impl Widget for HIDDebugOverlay {
type Output = HidOverlay;
fn spawn(self, world: &mut World, _screen_width: f32, _screen_height: f32) -> HidOverlay {
let padding = self.padding();
let line_height = self.line_height();
let width = font::text_width(&"M".repeat(COLUMNS), self.pixel_size) + padding * 2.0;
let height = line_height * LINES as f32 + padding * 2.0;
let panel = world
.spawn((
Position {
x: self.x,
y: self.y,
},
Size { width, height },
Background(self.background),
PanelMarker,
SceneEntity,
Name::new("HIDDebugOverlay"),
))
.id();
let lines = (0..LINES)
.map(|index| {
let entity = Text::at(
self.x + padding,
self.y + padding + line_height * index as f32,
"",
)
.pixel_size(self.pixel_size)
.color(self.color)
.spawn(world, 0.0, 0.0);
world.entity_mut(entity).insert(HidDebugLine(index));
entity
})
.collect::<Vec<_>>();
let overlay = HidOverlay { panel, lines };
world.entity_mut(panel).insert(overlay.clone());
overlay
}
}
pub fn update_hid_overlay_system(
pad: Res<GamepadState>,
mut lines: Query<(&HidDebugLine, &mut TextLine)>,
) {
let text = readout(&pad);
for (line, mut target) in &mut lines {
let wanted = text.get(line.0).map(String::as_str).unwrap_or("");
if target.text != wanted {
target.text.clear();
target.text.push_str(wanted);
}
}
}
fn readout(pad: &GamepadState) -> Vec<String> {
let mut lines = Vec::with_capacity(LINES);
let mut header = format!("PAD:{}", pad.name());
if let Some(battery) = pad.state.battery.filter(|_| pad.connected) {
let charge = match battery.charge {
ps::Charge::Discharging => "",
ps::Charge::Charging => " CHG",
ps::Charge::Full => " FULL",
ps::Charge::Error => " ERR",
};
header.push_str(&format!(" BATT:{}{}", battery.percent, charge));
}
lines.push(header);
if !pad.connected {
lines.resize(LINES, String::new());
return lines;
}
let state = &pad.state;
lines.push(format!(
"LX:{:3} LY:{:3} RX:{:3} RY:{:3}",
state.lx, state.ly, state.rx, state.ry
));
lines.push(format!(
"L2:{:3} R2:{:3} DPAD:{}",
state.l2,
state.r2,
state.dpad_name()
));
let touch = state
.touch
.iter()
.flatten()
.map(|touch| format!("{:04},{:04}", touch.x, touch.y))
.collect::<Vec<_>>();
lines.push(match touch.is_empty() {
true => "TOUCH:-".to_string(),
false => format!("TOUCH:{}", touch.join(" ")),
});
let (move_axis, look_axis) = (state.move_axis(), state.look_axis());
lines.push(format!(
"MOVE:{:+.2},{:+.2} LIFT:{:+.2}",
move_axis.x,
move_axis.y,
state.lift()
));
lines.push(format!("LOOK:{:+.2},{:+.2}", look_axis.x, look_axis.y));
let held = ps::button::NAMES
.iter()
.filter(|(mask, _)| state.held(*mask))
.map(|(_, name)| *name)
.collect::<Vec<_>>();
lines.extend(wrap(&held, COLUMNS - "HELD:".len(), "HELD:", HELD_LINES));
lines.resize(LINES, String::new());
lines
}
fn wrap(names: &[&str], columns: usize, prefix: &str, limit: usize) -> Vec<String> {
let indent = " ".repeat(prefix.len());
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for name in names {
let needed = if current.is_empty() {
name.len()
} else {
current.len() + 1 + name.len()
};
if needed > columns && !current.is_empty() {
lines.push(std::mem::take(&mut current));
if lines.len() == limit {
break;
}
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(name);
}
if lines.len() < limit && !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push("-".to_string());
}
lines
.into_iter()
.enumerate()
.map(|(i, line)| {
if i == 0 {
format!("{prefix}{line}")
} else {
format!("{indent}{line}")
}
})
.collect()
}
pub struct HidPlugin;
impl Plugin for HidPlugin {
fn build(&self, app: &mut Application) {
app.insert_resource(GamepadState::default());
app.add_update_systems(
update_hid_overlay_system.before(crate::ui::systems::collect_quads_system),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn connected(state: ps::State) -> GamepadState {
GamepadState {
connected: true,
model: Some(ps::Model::DualSense),
state,
}
}
#[test]
fn there_is_one_string_per_line_whatever_is_held() {
assert_eq!(readout(&GamepadState::default()).len(), LINES);
assert_eq!(readout(&connected(ps::State::default())).len(), LINES);
let everything = ps::State {
buttons: u32::MAX,
..ps::State::default()
};
assert_eq!(readout(&connected(everything)).len(), LINES);
}
#[test]
fn an_absent_pad_says_so_and_leaves_the_rest_blank() {
let lines = readout(&GamepadState::default());
assert_eq!(lines[0], "PAD:NOT CONNECTED");
assert!(lines[1..].iter().all(String::is_empty), "{lines:?}");
}
#[test]
fn what_is_held_is_named() {
let state = ps::State {
buttons: ps::button::CROSS | ps::button::L1,
..ps::State::default()
};
let lines = readout(&connected(state));
assert_eq!(lines[FIXED_LINES], "HELD:X L1");
let lines = readout(&connected(ps::State::default()));
assert_eq!(lines[FIXED_LINES], "HELD:-", "nothing held still reads");
}
#[test]
fn the_touchpad_shows_both_fingers_and_says_so_when_there_are_none() {
let lines = readout(&connected(ps::State::default()));
assert_eq!(lines[3], "TOUCH:-");
let one = ps::State {
touch: [
Some(ps::Touch {
id: 1,
x: 12,
y: 340,
}),
None,
],
..ps::State::default()
};
assert_eq!(readout(&connected(one))[3], "TOUCH:0012,0340");
}
#[test]
fn nothing_ever_runs_off_the_panel() {
let everything = ps::State {
buttons: u32::MAX,
lx: 255,
ly: 255,
rx: 255,
ry: 255,
l2: 255,
r2: 255,
dpad: 1,
battery: Some(ps::Battery {
percent: 100,
charge: ps::Charge::Full,
}),
touch: [
Some(ps::Touch {
id: 1,
x: 1919,
y: 1079,
}),
Some(ps::Touch {
id: 2,
x: 1919,
y: 1079,
}),
],
};
for line in readout(&connected(everything)) {
assert!(line.len() <= COLUMNS, "{} chars: {line:?}", line.len());
}
}
#[test]
fn a_wrapped_list_keeps_every_name_and_lines_up_under_the_prefix() {
let names = ["AAAA", "BBBB", "CCCC"];
let lines = wrap(&names, 9, "HELD:", 3);
assert_eq!(
lines,
vec!["HELD:AAAA BBBB".to_string(), " CCCC".to_string()]
);
}
}