codecraft 0.2.0

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, a yakui-drawn UI, audio and gamepad haptics; its binary maps any folder, and the symbols of its Rust files, as a 3D wall of boxes
Documentation
//! A readout of what the controller is sending, on a panel in the corner.
//! ```no_run
//! # use codecraft::AppState;
//! # fn demo(app: &mut AppState) {
//! codecraft::hid::overlay::show(&app.gamepad());
//! # }
//! ```
use yakui::Alignment;
use yakui::geometry::{Constraints, Vec2};
use yakui::widgets::List;

use super::{GamepadState, ps};
use crate::ecs::{Application, Plugin};
use crate::ui;

const COLUMNS: usize = 30;

const FIXED_LINES: usize = 6;

const HELD_LINES: usize = 3;

const LINES: usize = FIXED_LINES + HELD_LINES;

const PX: f32 = 16.0;

/// Draws the readout in the top-left corner, sized for the widest possible line so it never resizes.
pub fn show(pad: &GamepadState) {
    let width = COLUMNS as f32 * PX * 0.62 + ui::PANEL_PADDING * 2.0;
    ui::corner(Alignment::TOP_LEFT, || {
        ui::panel_colored(yakui::geometry::Color::rgba(15, 15, 23, 204), || {
            yakui::constrained(
                Constraints {
                    min: Vec2::new(width, 0.0),
                    max: Vec2::new(width, f32::INFINITY),
                },
                || {
                    let mut column = List::column();
                    column.main_axis_size = yakui::MainAxisSize::Min;
                    column.item_spacing = 4.0;
                    column.show(|| {
                        for line in readout(pad) {
                            // An empty line still takes its room, so the panel height is constant.
                            let line = match line.is_empty() {
                                true => " ".to_string(),
                                false => line,
                            };
                            ui::text_colored(PX, line, yakui::geometry::Color::rgb(217, 224, 242));
                        }
                    });
                },
            );
        });
    });
}

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
}

/// Lays `names` out over at most `limit` lines of `columns` characters, indented under `prefix`.
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()
}

/// Registers the pad state the readout is drawn from.
pub struct HidPlugin;

impl Plugin for HidPlugin {
    fn build(&self, app: &mut Application) {
        app.insert_resource(GamepadState::default());
    }
}

#[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()]
        );
    }
}