483 lines
13 KiB
Rust
483 lines
13 KiB
Rust
#![no_std]
|
|
|
|
extern crate alloc;
|
|
|
|
use alloc::string::String;
|
|
use core::str::FromStr;
|
|
|
|
use owned_str::OwnedStr;
|
|
use serde::{Deserialize, Serialize};
|
|
use ufmt::uwrite;
|
|
|
|
const ARROW_RIGHT: char = char::from_u32(0b0111_1110).unwrap();
|
|
const ARROW_LEFT: char = char::from_u32(0b0111_1111).unwrap();
|
|
const DOT: char = char::from_u32(0b1010_0101).unwrap();
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ViewState {
|
|
Loading,
|
|
Reconnecting,
|
|
ConnectPrompt,
|
|
WaitingForParty,
|
|
Question,
|
|
Results,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone, Copy)]
|
|
pub enum QuestionType {
|
|
Choice,
|
|
Numeric { min: i32, max: i32 },
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct QuestionData {
|
|
pub text: OwnedStr<256>,
|
|
pub q_type: QuestionType,
|
|
pub points: i32,
|
|
pub index: usize,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct QuestionDataNet<'a> {
|
|
pub text: &'a str,
|
|
pub q_type: QuestionType,
|
|
pub points: i32,
|
|
pub index: usize,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub enum ProxyOutput<'a> {
|
|
ConnectPrompt(&'a str),
|
|
WaitingForParty,
|
|
Question(QuestionDataNet<'a>),
|
|
Results,
|
|
Error(&'a str),
|
|
}
|
|
|
|
impl<'a> From<QuestionDataNet<'a>> for QuestionData {
|
|
fn from(value: QuestionDataNet<'a>) -> Self {
|
|
QuestionData {
|
|
text: OwnedStr::from_str(value.text).unwrap(),
|
|
q_type: value.q_type,
|
|
points: value.points,
|
|
index: value.index,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct WheelData {
|
|
pub value: i32,
|
|
pub min: i32,
|
|
pub max: i32,
|
|
pub accumulated: i32,
|
|
}
|
|
|
|
impl WheelData {
|
|
pub const fn empty() -> Self {
|
|
Self {
|
|
value: 0,
|
|
min: 0,
|
|
max: 0,
|
|
accumulated: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct DeviceState {
|
|
view: ViewState,
|
|
device_id: Option<OwnedStr<64>>,
|
|
question: Option<QuestionData>,
|
|
wheel: WheelData,
|
|
last_index: usize,
|
|
title_offset: usize,
|
|
}
|
|
|
|
impl DeviceState {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
view: ViewState::Loading,
|
|
device_id: None,
|
|
question: None,
|
|
wheel: WheelData::empty(),
|
|
last_index: 0,
|
|
title_offset: 0,
|
|
}
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
self.view = ViewState::Loading;
|
|
self.question = None;
|
|
self.wheel = WheelData::empty();
|
|
self.last_index = 0;
|
|
self.title_offset = 0;
|
|
}
|
|
|
|
pub fn reconnecting(&mut self) {
|
|
self.question = None;
|
|
self.wheel = WheelData::empty();
|
|
self.view = ViewState::Reconnecting;
|
|
}
|
|
|
|
pub fn view_state(&self) -> ViewState {
|
|
self.view
|
|
}
|
|
|
|
pub fn wheel(&self) -> WheelData {
|
|
self.wheel
|
|
}
|
|
|
|
pub fn wheel_mut(&mut self) -> &mut WheelData {
|
|
&mut self.wheel
|
|
}
|
|
|
|
pub fn apply_proxy_output(&mut self, data: ProxyOutput<'_>) {
|
|
match data {
|
|
ProxyOutput::ConnectPrompt(device_id) => {
|
|
let mut owned_device_id = OwnedStr::new();
|
|
for char in device_id.chars() {
|
|
if owned_device_id.try_push(char).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
self.device_id = Some(owned_device_id);
|
|
self.question = None;
|
|
self.view = ViewState::ConnectPrompt;
|
|
}
|
|
ProxyOutput::WaitingForParty => {
|
|
self.question = None;
|
|
self.view = ViewState::WaitingForParty;
|
|
}
|
|
ProxyOutput::Question(data) => {
|
|
let data: QuestionData = data.into();
|
|
let mut future_wheel = WheelData::empty();
|
|
if let QuestionType::Numeric { min, max } = data.q_type {
|
|
future_wheel.max = max;
|
|
future_wheel.min = min;
|
|
future_wheel.value = (min + max) / 2;
|
|
}
|
|
self.question = Some(data);
|
|
self.view = ViewState::Question;
|
|
self.wheel = future_wheel;
|
|
}
|
|
ProxyOutput::Results => {
|
|
self.view = ViewState::Results;
|
|
}
|
|
ProxyOutput::Error(_) => {}
|
|
}
|
|
}
|
|
|
|
pub fn question(&self) -> Option<&QuestionData> {
|
|
self.question.as_ref()
|
|
}
|
|
|
|
pub fn tick(&mut self) {
|
|
if self.view != ViewState::Question {
|
|
return;
|
|
}
|
|
let Some(question) = self.question.as_ref() else {
|
|
return;
|
|
};
|
|
self.title_offset = self.title_offset.wrapping_add(1);
|
|
if question.index != self.last_index {
|
|
self.last_index = question.index;
|
|
self.title_offset = 0;
|
|
}
|
|
}
|
|
|
|
pub fn render_lines(&mut self) -> Option<(OwnedStr<16>, OwnedStr<16>)> {
|
|
if self.view == ViewState::Loading {
|
|
return Some((
|
|
OwnedStr::from_str("Connecting").unwrap(),
|
|
OwnedStr::from_str("Please wait").unwrap(),
|
|
));
|
|
}
|
|
|
|
if self.view == ViewState::Reconnecting {
|
|
return Some((
|
|
OwnedStr::from_str("Reconnecting").unwrap(),
|
|
OwnedStr::from_str("Please wait").unwrap(),
|
|
));
|
|
}
|
|
|
|
if self.view == ViewState::ConnectPrompt {
|
|
let device_id = self.device_id.as_ref()?;
|
|
let mut display_id: OwnedStr<16> = OwnedStr::new();
|
|
for char in device_id.as_str().chars() {
|
|
if display_id.try_push(char).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
return Some((
|
|
OwnedStr::from_str("Connect device").unwrap(),
|
|
center_str::<16>(display_id.as_str(), 16).unwrap(),
|
|
));
|
|
}
|
|
|
|
if self.view == ViewState::WaitingForParty {
|
|
return Some((
|
|
OwnedStr::from_str("Connected").unwrap(),
|
|
OwnedStr::from_str("Waiting party").unwrap(),
|
|
));
|
|
}
|
|
|
|
if self.view != ViewState::Question {
|
|
return None;
|
|
}
|
|
|
|
let question = self.question.as_ref()?;
|
|
let title_line = if question.text.len() > 16 {
|
|
// overscroll, should show spaces after the end
|
|
self.title_offset %= question.text.len() - 31;
|
|
let end = usize::min(self.title_offset + 16, question.text.len());
|
|
OwnedStr::from_str(&question.text[self.title_offset..end]).unwrap()
|
|
} else {
|
|
OwnedStr::from_str(&question.text).unwrap()
|
|
};
|
|
|
|
let number_str: OwnedStr<16> = match question.q_type {
|
|
QuestionType::Choice => {
|
|
let mut writer = OwnedStrWriter::new();
|
|
writer.push(DOT).unwrap();
|
|
writer.push(' ').unwrap();
|
|
uwrite!(writer, "{}", question.points).unwrap();
|
|
writer.into()
|
|
}
|
|
QuestionType::Numeric { min, max } => {
|
|
let mut writer = OwnedStrWriter::new();
|
|
if self.wheel.value > min {
|
|
writer.push(ARROW_LEFT).unwrap();
|
|
writer.push(' ').unwrap();
|
|
}
|
|
uwrite!(writer, "{}", self.wheel.value).unwrap();
|
|
if self.wheel.value < max {
|
|
writer.push(ARROW_RIGHT).unwrap();
|
|
writer.push(' ').unwrap();
|
|
}
|
|
|
|
writer.into()
|
|
}
|
|
};
|
|
let second_line = center_str::<16>(&number_str, 16).unwrap();
|
|
Some((title_line, second_line))
|
|
}
|
|
|
|
pub fn response_value(&self, button: u8) -> i32 {
|
|
match self.question.as_ref() {
|
|
Some(q) => match q.q_type {
|
|
QuestionType::Numeric { .. } => self.wheel.value,
|
|
QuestionType::Choice => button as i32,
|
|
},
|
|
_ => button as i32,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub enum WriteType<'a> {
|
|
QuizResponse(i32),
|
|
DeviceId(&'a str),
|
|
}
|
|
|
|
pub fn parse_proxy_output<'a>(input: &'a str) -> Result<ProxyOutput<'a>, serde_json::Error> {
|
|
serde_json::from_str::<ProxyOutput<'a>>(input)
|
|
}
|
|
|
|
pub fn serialize_write(data: &WriteType<'_>) -> Result<String, serde_json::Error> {
|
|
serde_json::to_string(data)
|
|
}
|
|
|
|
pub const WHEEL_TICKS: i32 = 4096;
|
|
|
|
pub fn wheel_delta(old_angle: i32, current_angle: i32, inverted: bool) -> i32 {
|
|
let mut diff = current_angle - old_angle;
|
|
if diff.abs() > WHEEL_TICKS / 2 {
|
|
diff = if diff > 0 {
|
|
diff - WHEEL_TICKS
|
|
} else {
|
|
diff + WHEEL_TICKS
|
|
};
|
|
}
|
|
|
|
if inverted { -diff } else { diff }
|
|
}
|
|
|
|
pub fn apply_wheel_delta(
|
|
value: &mut i32,
|
|
min: i32,
|
|
max: i32,
|
|
accumulated: &mut i32,
|
|
diff: i32,
|
|
precision: i32,
|
|
) {
|
|
if max == min || precision <= 0 {
|
|
return;
|
|
}
|
|
|
|
*accumulated += diff;
|
|
let step_count = *accumulated / precision;
|
|
if step_count == 0 {
|
|
return;
|
|
}
|
|
|
|
*accumulated -= step_count * precision;
|
|
*value += step_count;
|
|
if *value < min {
|
|
*value = min;
|
|
} else if *value > max {
|
|
*value = max;
|
|
}
|
|
if *value == min || *value == max {
|
|
*accumulated = 0;
|
|
}
|
|
}
|
|
|
|
pub struct OwnedStrWriter<const CAP: usize>(OwnedStr<CAP>);
|
|
|
|
impl<const CAP: usize> OwnedStrWriter<CAP> {
|
|
pub fn new() -> Self {
|
|
Self(OwnedStr::new())
|
|
}
|
|
|
|
pub fn push(&mut self, c: char) -> Result<(), owned_str::Error> {
|
|
self.0.try_push(c).map(|_| ())
|
|
}
|
|
}
|
|
|
|
impl<const CAP: usize> From<OwnedStr<CAP>> for OwnedStrWriter<CAP> {
|
|
fn from(owned_str: OwnedStr<CAP>) -> Self {
|
|
Self(owned_str)
|
|
}
|
|
}
|
|
|
|
impl<const CAP: usize> From<OwnedStrWriter<CAP>> for OwnedStr<CAP> {
|
|
fn from(writer: OwnedStrWriter<CAP>) -> Self {
|
|
writer.0
|
|
}
|
|
}
|
|
|
|
impl<const CAP: usize> ufmt::uWrite for OwnedStrWriter<CAP> {
|
|
type Error = owned_str::Error;
|
|
fn write_str(&mut self, s: &str) -> Result<(), Self::Error> {
|
|
self.0.try_push_str(s).map(|_| ())
|
|
}
|
|
fn write_char(&mut self, c: char) -> Result<(), Self::Error> {
|
|
self.0.try_push(c).map(|_| ())
|
|
}
|
|
}
|
|
|
|
pub fn center_str<const CAP: usize>(
|
|
text: &str,
|
|
width: usize,
|
|
) -> Result<OwnedStr<CAP>, owned_str::Error> {
|
|
let mut res = OwnedStr::new();
|
|
let len = text.len();
|
|
let padding = (width.saturating_sub(len) + 1) / 2;
|
|
for _ in 0..padding {
|
|
res.try_push(' ')?;
|
|
}
|
|
res.try_push_str(text)?;
|
|
Ok(res)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{DeviceState, ViewState, apply_wheel_delta, parse_proxy_output, wheel_delta};
|
|
|
|
#[test]
|
|
fn parses_and_renders_connect_prompt() {
|
|
let data = parse_proxy_output(r#"{"ConnectPrompt":"esp32-1"}"#).unwrap();
|
|
let mut state = DeviceState::new();
|
|
|
|
state.apply_proxy_output(data);
|
|
|
|
assert_eq!(state.view_state(), ViewState::ConnectPrompt);
|
|
let (line1, line2) = state.render_lines().unwrap();
|
|
assert_eq!(line1.as_str(), "Connect device");
|
|
assert_eq!(line2.as_str(), " esp32-1");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_results_message() {
|
|
let data = parse_proxy_output(r#""Results""#).unwrap();
|
|
let mut state = DeviceState::new();
|
|
|
|
state.apply_proxy_output(data);
|
|
|
|
assert_eq!(state.view_state(), ViewState::Results);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_and_renders_waiting_for_party() {
|
|
let data = parse_proxy_output(r#""WaitingForParty""#).unwrap();
|
|
let mut state = DeviceState::new();
|
|
|
|
state.apply_proxy_output(data);
|
|
|
|
assert_eq!(state.view_state(), ViewState::WaitingForParty);
|
|
let (line1, line2) = state.render_lines().unwrap();
|
|
assert_eq!(line1.as_str(), "Connected");
|
|
assert_eq!(line2.as_str(), "Waiting party");
|
|
}
|
|
|
|
#[test]
|
|
fn renders_reconnecting_state() {
|
|
let mut state = DeviceState::new();
|
|
|
|
state.reconnecting();
|
|
|
|
assert_eq!(state.view_state(), ViewState::Reconnecting);
|
|
let (line1, line2) = state.render_lines().unwrap();
|
|
assert_eq!(line1.as_str(), "Reconnecting");
|
|
assert_eq!(line2.as_str(), "Please wait");
|
|
}
|
|
|
|
#[test]
|
|
fn renders_loading_state() {
|
|
let mut state = DeviceState::new();
|
|
|
|
let (line1, line2) = state.render_lines().unwrap();
|
|
|
|
assert_eq!(line1.as_str(), "Connecting");
|
|
assert_eq!(line2.as_str(), "Please wait");
|
|
}
|
|
|
|
#[test]
|
|
fn wraps_forward_across_zero() {
|
|
assert_eq!(wheel_delta(4090, 5, false), 11);
|
|
}
|
|
|
|
#[test]
|
|
fn wraps_backward_across_zero() {
|
|
assert_eq!(wheel_delta(5, 4090, false), -11);
|
|
}
|
|
|
|
#[test]
|
|
fn inverts_direction() {
|
|
assert_eq!(wheel_delta(10, 20, true), -10);
|
|
}
|
|
|
|
#[test]
|
|
fn accumulates_before_applying_selection() {
|
|
let mut value = 5;
|
|
let mut accumulated = 0;
|
|
|
|
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 10, 32);
|
|
assert_eq!(value, 5);
|
|
assert_eq!(accumulated, 10);
|
|
|
|
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 22, 32);
|
|
assert_eq!(value, 6);
|
|
assert_eq!(accumulated, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn clamps_and_resets_at_bounds() {
|
|
let mut value = 10;
|
|
let mut accumulated = 0;
|
|
|
|
apply_wheel_delta(&mut value, 0, 10, &mut accumulated, 64, 32);
|
|
assert_eq!(value, 10);
|
|
assert_eq!(accumulated, 0);
|
|
}
|
|
}
|