#![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> 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>, question: Option, 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, serde_json::Error> { serde_json::from_str::>(input) } pub fn serialize_write(data: &WriteType<'_>) -> Result { 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(OwnedStr); impl OwnedStrWriter { 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 From> for OwnedStrWriter { fn from(owned_str: OwnedStr) -> Self { Self(owned_str) } } impl From> for OwnedStr { fn from(writer: OwnedStrWriter) -> Self { writer.0 } } impl ufmt::uWrite for OwnedStrWriter { 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( text: &str, width: usize, ) -> Result, 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); } }