From a910015856a993ee360f92bac60a85fac2cb982f Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 18 Jan 2026 17:09:42 +0100 Subject: [PATCH] better ui with 3 lines now --- mqtt_display/Cargo.toml | 2 +- mqtt_display/src/contracts.rs | 4 +- mqtt_display/src/display/api.rs | 35 +---- mqtt_display/src/display/task.rs | 56 +++---- mqtt_display/src/display/tui.rs | 253 +++++++++++++++++-------------- 5 files changed, 175 insertions(+), 175 deletions(-) diff --git a/mqtt_display/Cargo.toml b/mqtt_display/Cargo.toml index 2c7365c..9efb5ea 100644 --- a/mqtt_display/Cargo.toml +++ b/mqtt_display/Cargo.toml @@ -9,7 +9,7 @@ name = "projekt_final" path = "./src/bin/main.rs" [dependencies] -pages-tui = { path = "../../../pages-tui", default-features = false } +pages-tui = { path = "../../../pages-tui" } esp-bootloader-esp-idf = { version = "0.2.0", features = ["esp32"] } esp-hal = { version = "=1.0.0-rc.0", features = [ "esp32", diff --git a/mqtt_display/src/contracts.rs b/mqtt_display/src/contracts.rs index d676b51..56b48ff 100644 --- a/mqtt_display/src/contracts.rs +++ b/mqtt_display/src/contracts.rs @@ -10,11 +10,8 @@ use pages_tui::input::Key; /// IMU sensor reading from MPU6050 #[derive(Clone, Copy, Default, Debug)] pub struct ImuReading { - /// Acceleration in g (earth gravity units) pub accel_g: [f32; 3], - /// Angular velocity in degrees per second pub gyro_dps: [f32; 3], - /// Temperature in Celsius pub temp_c: f32, /// Timestamp in milliseconds since boot pub timestamp_ms: u64, @@ -35,4 +32,5 @@ pub enum DisplayCommand { Clear, PushKey(Key), + AddChatMessage(HString<24>), } diff --git a/mqtt_display/src/display/api.rs b/mqtt_display/src/display/api.rs index fb9128c..5d15968 100644 --- a/mqtt_display/src/display/api.rs +++ b/mqtt_display/src/display/api.rs @@ -1,8 +1,5 @@ // src/display/api.rs //! Public API for the display feature. -//! -//! Other parts of the system use this module to send commands to the display. -//! The actual rendering happens in `task.rs`. use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::{Channel, Receiver, TrySendError}; @@ -11,72 +8,47 @@ use heapless::String; use crate::contracts::{DisplayCommand, ImuReading}; use pages_tui::input::Key; -/// Queue size for display commands. -/// Moderate size to handle bursts without dropping. const QUEUE_SIZE: usize = 8; -/// Channel for sending commands to the display task. pub(crate) static DISPLAY_CHANNEL: Channel = Channel::new(); -/// Send a command to the display. -/// -/// This is async and will wait if the queue is full. -/// For fire-and-forget, use `try_send`. -/// -/// # Example -/// ```ignore -/// display::api::send(DisplayCommand::SetStatus("Hello".try_into().unwrap())).await; -/// ``` pub async fn send(cmd: DisplayCommand) { DISPLAY_CHANNEL.send(cmd).await; } -/// Try to send a command without waiting. -/// -/// Returns `Err(cmd)` if the queue is full. pub fn try_send(cmd: DisplayCommand) -> Result<(), DisplayCommand> { DISPLAY_CHANNEL.try_send(cmd).map_err(|e| match e { TrySendError::Full(command) => command, }) } -/// Get a receiver for display commands (internal use). -/// -/// Used by the display task to receive commands. pub(crate) fn receiver() -> Receiver<'static, CriticalSectionRawMutex, DisplayCommand, QUEUE_SIZE> { DISPLAY_CHANNEL.receiver() } -// ───────────────────────────────────────────────────────────────────────────── -// Convenience functions for common commands // ───────────────────────────────────────────────────────────────────────────── -/// Send IMU data to the display. pub async fn show_imu(reading: ImuReading) { send(DisplayCommand::SetImu(reading)).await; } -/// Set the status line. pub async fn set_status(text: &str) { let mut s = String::<32>::new(); let _ = s.push_str(&text[..text.len().min(32)]); send(DisplayCommand::SetStatus(s)).await; } -/// Show an error message. pub async fn show_error(text: &str) { let mut s = String::<64>::new(); let _ = s.push_str(&text[..text.len().min(64)]); send(DisplayCommand::ShowError(s)).await; } -/// Update MQTT status indicator. pub async fn set_mqtt_status(connected: bool, msg_count: u32) { send(DisplayCommand::SetMqttStatus { connected, msg_count }).await; } -/// Clear the display. pub async fn clear() { send(DisplayCommand::Clear).await; } @@ -84,3 +56,10 @@ pub async fn clear() { pub async fn push_key(key: Key) { send(DisplayCommand::PushKey(key)).await; } + +/// Add a chat message (for Chat page) +pub async fn add_chat_message(msg: &str) { + let mut s = String::<24>::new(); + let _ = s.push_str(&msg[..msg.len().min(24)]); + send(DisplayCommand::AddChatMessage(s)).await; +} diff --git a/mqtt_display/src/display/task.rs b/mqtt_display/src/display/task.rs index a6d1f67..914f4db 100644 --- a/mqtt_display/src/display/task.rs +++ b/mqtt_display/src/display/task.rs @@ -25,17 +25,13 @@ pub async fn display_task(i2c: I2cDevice) { if let Err(e) = display.init() { error!("Display init failed: {:?}", e); - loop { - Timer::after(Duration::from_secs(60)).await; - } + loop { Timer::after(Duration::from_secs(60)).await; } } let config = EmbeddedBackendConfig { - flush_callback: Box::new( - |d: &mut Ssd1306<_, _, BufferedGraphicsMode>| { - let _ = d.flush(); - }, - ), + flush_callback: Box::new(|d: &mut Ssd1306<_, _, BufferedGraphicsMode>| { + let _ = d.flush(); + }), ..Default::default() }; @@ -46,47 +42,43 @@ pub async fn display_task(i2c: I2cDevice) { let mut orchestrator = Orchestrator::::new(); let rx = receiver(); - // Register all pages + // Register pages + orchestrator.register_page("menu".into(), Screen::Menu); orchestrator.register_page("imu".into(), Screen::Imu); - orchestrator.register_page("mqtt".into(), Screen::Mqtt); - orchestrator.register_page("status".into(), Screen::Status); + orchestrator.register_page("chat".into(), Screen::Chat); - // Key bindings: - // - Tab (Next button): cycle focus between Prev/Next buttons - // - Enter (Select button): trigger the focused button's action orchestrator.bind(Key::tab(), ComponentAction::Next); orchestrator.bind(Key::enter(), ComponentAction::Select); - // Navigate to initial page - let _ = orchestrator.navigate_to("imu".into()); + let _ = orchestrator.navigate_to("menu".into()); + + info!("Display ready"); loop { while let Ok(cmd) = rx.try_receive() { match cmd { DisplayCommand::PushKey(key) => { if key == Key::tab() { - // Cycle focus between buttons orchestrator.focus_manager_mut().wrap_next(); - info!("Focus changed to: {:?}", orchestrator.focus_manager().current()); } else if key == Key::enter() { - // Process Select - this triggers Component::handle() - let events = orchestrator.process_frame(key); - - // Handle navigation events - if let Ok(events) = events { + if let Ok(events) = orchestrator.process_frame(key) { for event in events { match event { + ScreenEvent::GoToImu => { + let _ = orchestrator.navigate_to("imu".into()); + } + ScreenEvent::GoToChat => { + let _ = orchestrator.navigate_to("chat".into()); + } ScreenEvent::NavigatePrev => { - if let Some(current) = orchestrator.current_id() { - let prev = prev_page_id(current.as_str()); - info!("Navigating to prev: {}", prev); + if let Some(cur) = orchestrator.current_id() { + let prev = prev_page_id(cur.as_str()); let _ = orchestrator.navigate_to(prev.into()); } } ScreenEvent::NavigateNext => { - if let Some(current) = orchestrator.current_id() { - let next = next_page_id(current.as_str()); - info!("Navigating to next: {}", next); + if let Some(cur) = orchestrator.current_id() { + let next = next_page_id(cur.as_str()); let _ = orchestrator.navigate_to(next.into()); } } @@ -99,10 +91,8 @@ pub async fn display_task(i2c: I2cDevice) { } } - // Render current state - if let Some(current_screen) = orchestrator.current() { - let focused = orchestrator.focus_manager().current(); - render_frame(&mut terminal, current_screen, focused, &state); + if let Some(screen) = orchestrator.current() { + render_frame(&mut terminal, screen, orchestrator.focus_manager().current(), &state); } Timer::after(Duration::from_millis(REFRESH_INTERVAL_MS)).await; diff --git a/mqtt_display/src/display/tui.rs b/mqtt_display/src/display/tui.rs index ff8f041..829bfd4 100644 --- a/mqtt_display/src/display/tui.rs +++ b/mqtt_display/src/display/tui.rs @@ -4,32 +4,36 @@ use alloc::format; use heapless::String as HString; use pages_tui::prelude::*; use ratatui::{ - layout::{Constraint, Direction, Layout}, + layout::Rect, style::Stylize, widgets::Paragraph, Terminal, }; use log::info; -/// Focusable buttons available on every page +/// Focus targets - different per screen #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum PageButton { - Prev, - Next, +pub enum PageFocus { + MenuImu, + MenuChat, + NavPrev, + NavNext, } -impl FocusId for PageButton {} +impl FocusId for PageFocus {} -/// All available buttons for iteration -const PAGE_BUTTONS: &[PageButton] = &[PageButton::Prev, PageButton::Next]; +const MENU_TARGETS: &[PageFocus] = &[PageFocus::MenuImu, PageFocus::MenuChat]; +const NAV_TARGETS: &[PageFocus] = &[PageFocus::NavPrev, PageFocus::NavNext]; -/// Display state shared between pages +/// Display state pub struct DisplayState { pub(crate) status: HString<32>, pub(crate) last_imu: Option, pub(crate) last_error: Option>, pub(crate) mqtt_connected: bool, pub(crate) mqtt_msg_count: u32, + pub(crate) chat_msg1: HString<24>, + pub(crate) chat_msg2: HString<24>, } impl Default for DisplayState { @@ -40,6 +44,8 @@ impl Default for DisplayState { last_error: None, mqtt_connected: false, mqtt_msg_count: 0, + chat_msg1: HString::new(), + chat_msg2: HString::new(), } } } @@ -63,20 +69,30 @@ impl DisplayState { self.status = HString::new(); } DisplayCommand::PushKey(_) => {} + DisplayCommand::AddChatMessage(msg) => { + self.add_chat_message(msg.as_str()); + } } } + + pub fn add_chat_message(&mut self, msg: &str) { + self.chat_msg2 = self.chat_msg1.clone(); + self.chat_msg1.clear(); + let _ = self.chat_msg1.push_str(&msg[..msg.len().min(24)]); + } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum Screen { + Menu, Imu, - Mqtt, - Status, + Chat, } -/// Events emitted by screens for navigation #[derive(Clone, Debug)] pub enum ScreenEvent { + GoToImu, + GoToChat, NavigatePrev, NavigateNext, } @@ -84,46 +100,45 @@ pub enum ScreenEvent { impl Component for Screen { type Action = ComponentAction; type Event = ScreenEvent; - type Focus = PageButton; + type Focus = PageFocus; fn handle( &mut self, focus: &Self::Focus, action: Self::Action, ) -> Result, ComponentError> { - match action { - ComponentAction::Select => { - info!("Select on {:?} screen, focused: {:?}", self, focus); - // When Select is pressed, trigger navigation based on focused button - match focus { - PageButton::Prev => return Ok(Some(ScreenEvent::NavigatePrev)), - PageButton::Next => return Ok(Some(ScreenEvent::NavigateNext)), - } - } - _ => {} + if let ComponentAction::Select = action { + info!("Select {:?} focus:{:?}", self, focus); + return Ok(Some(match focus { + PageFocus::MenuImu => ScreenEvent::GoToImu, + PageFocus::MenuChat => ScreenEvent::GoToChat, + PageFocus::NavPrev => ScreenEvent::NavigatePrev, + PageFocus::NavNext => ScreenEvent::NavigateNext, + })); } Ok(None) } fn targets(&self) -> &[Self::Focus] { - // Every page has Prev and Next buttons - PAGE_BUTTONS + match self { + Screen::Menu => MENU_TARGETS, + Screen::Imu | Screen::Chat => NAV_TARGETS, + } } fn on_enter(&mut self) -> Result<(), ComponentError> { - info!("Entered screen: {:?}", self); + info!("Enter: {:?}", self); Ok(()) } fn on_exit(&mut self) -> Result<(), ComponentError> { - info!("Exited screen: {:?}", self); Ok(()) } } // ============ PAGE ORDER ============ -const PAGE_ORDER: &[&str] = &["imu", "mqtt", "status"]; +const PAGE_ORDER: &[&str] = &["menu", "imu", "chat"]; pub fn next_page_id(current: &str) -> &'static str { let idx = PAGE_ORDER.iter().position(|&p| p == current).unwrap_or(0); @@ -132,102 +147,120 @@ pub fn next_page_id(current: &str) -> &'static str { pub fn prev_page_id(current: &str) -> &'static str { let idx = PAGE_ORDER.iter().position(|&p| p == current).unwrap_or(0); - if idx == 0 { - PAGE_ORDER[PAGE_ORDER.len() - 1] - } else { - PAGE_ORDER[idx - 1] - } + if idx == 0 { PAGE_ORDER[PAGE_ORDER.len() - 1] } else { PAGE_ORDER[idx - 1] } } -// ============ RENDERING ============ +// ============ RENDERING - NO LAYOUT, DIRECT RECTS ============ +// 128x32 display = 21 chars x 4 rows (6x8 font) -fn render_imu_content(state: &DisplayState) -> alloc::string::String { - if let Some(ref imu) = state.last_imu { - format!( - "G:{:.0} {:.0} {:.0}\nT:{:.1}C", - imu.gyro_dps[0], imu.gyro_dps[1], imu.gyro_dps[2], imu.temp_c - ) - } else { - format!("Waiting IMU...") - } -} - -fn render_mqtt_content(state: &DisplayState) -> alloc::string::String { - format!( - "MQTT:{}\nMsg:{}", - if state.mqtt_connected { "ON" } else { "OFF" }, - state.mqtt_msg_count - ) -} - -fn render_status_content(state: &DisplayState) -> alloc::string::String { - format!("Status:\n{}", state.status.as_str()) -} - -fn render_button(label: &str, focused: bool) -> Paragraph<'static> { - let text = if focused { - format!("[>{}]", label) - } else { - format!(" {} ", label) - }; - - if focused { - Paragraph::new(text).white().on_blue() - } else { - Paragraph::new(text).gray() - } +/// Get row rect (0-3) for 128x32 display +#[inline] +fn row(area: Rect, n: u16) -> Rect { + Rect::new(area.x, area.y + n, area.width, 1) } pub fn render_frame( terminal: &mut Terminal, current_screen: &Screen, - focused_button: Option<&PageButton>, + focused: Option<&PageFocus>, state: &DisplayState, ) { let _ = terminal.draw(|f| { - // Handle errors with priority + let area = f.area(); + if let Some(ref err) = state.last_error { - let content = format!("ERR: {}", err.as_str()); - f.render_widget(Paragraph::new(content).red().bold(), f.area()); + f.render_widget(Paragraph::new(format!("ERR:{}", err.as_str())).red().bold(), area); return; } - // Layout: content area + button bar - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(1), // Content - Constraint::Length(1), // Button bar - ]) - .split(f.area()); - - // Render page content - let content = match current_screen { - Screen::Imu => render_imu_content(state), - Screen::Mqtt => render_mqtt_content(state), - Screen::Status => render_status_content(state), - }; - - let style = match current_screen { - Screen::Imu => Paragraph::new(content).cyan(), - Screen::Mqtt => Paragraph::new(content).green(), - Screen::Status => Paragraph::new(content).yellow(), - }; - f.render_widget(style, chunks[0]); - - // Render button bar - let btn_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]) - .split(chunks[1]); - - let prev_focused = focused_button == Some(&PageButton::Prev); - let next_focused = focused_button == Some(&PageButton::Next); - - f.render_widget(render_button("<", prev_focused), btn_chunks[0]); - f.render_widget(render_button(">", next_focused), btn_chunks[1]); + match current_screen { + Screen::Menu => render_menu(f, area, focused, state), + Screen::Imu => render_imu(f, area, focused, state), + Screen::Chat => render_chat(f, area, focused, state), + } }); } + +fn render_menu(f: &mut ratatui::Frame, area: Rect, focused: Option<&PageFocus>, state: &DisplayState) { + // Row 0 Title + let conn = if state.mqtt_connected { "*" } else { "x" }; + f.render_widget( + Paragraph::new(format!("-- ESP32 Hub {} --", conn)).white().bold(), + row(area, 0), + ); + + // Row 1 Sensors + let imu_sel = focused == Some(&PageFocus::MenuImu); + f.render_widget( + Paragraph::new(if imu_sel { " > Sensors" } else { " Sensors" }) + .style(if imu_sel { ratatui::style::Style::default().white().bold() } else { ratatui::style::Style::default().white() }), + row(area, 1), + ); + + // Row 2 Messages + let chat_sel = focused == Some(&PageFocus::MenuChat); + f.render_widget( + Paragraph::new(if chat_sel { " > Messages" } else { " Messages" }) + .style(if chat_sel { ratatui::style::Style::default().white().bold() } else { ratatui::style::Style::default().white() }), + row(area, 2), + ); +} + +fn render_imu(f: &mut ratatui::Frame, area: Rect, focused: Option<&PageFocus>, state: &DisplayState) { + if let Some(ref imu) = state.last_imu { + // Row 0 Gyro + // f.render_widget( + // Paragraph::new(format!("G:{:+5.0}{:+5.0}{:+5.0}", imu.gyro_dps[0], imu.gyro_dps[1], imu.gyro_dps[2])).cyan(), + // row(area, 0), + // ); + // Row 1 Accel + f.render_widget( + Paragraph::new(format!("A:{:+.1} {:+.1} {:+.1}", imu.accel_g[0], imu.accel_g[1], imu.accel_g[2])).green(), + row(area, 1), + ); + // Row 2 Temp + f.render_widget( + Paragraph::new(format!("T: {:.1}C", imu.temp_c)).yellow(), + row(area, 2), + ); + } else { + f.render_widget(Paragraph::new("Waiting for MPU...").white(), row(area, 1)); + } + + // Row 3 Nav bar + render_nav_bar(f, row(area, 2), focused); +} + +fn render_chat(f: &mut ratatui::Frame, area: Rect, focused: Option<&PageFocus>, state: &DisplayState) { + // Row 0 Message 1 + if state.chat_msg1.is_empty() { + f.render_widget(Paragraph::new("(no messages)").white(), row(area, 0)); + } else { + f.render_widget(Paragraph::new(format!(">{}", state.chat_msg1.as_str())).green(), row(area, 0)); + } + + // Row 1 Message 2 + if !state.chat_msg2.is_empty() { + f.render_widget(Paragraph::new(format!(">{}", state.chat_msg2.as_str())).white(), row(area, 1)); + } + + // Row 3 Nav bar + render_nav_bar(f, row(area, 3), focused); +} + +/// Nav bar: --[<]-------[>]-- +fn render_nav_bar(f: &mut ratatui::Frame, area: Rect, focused: Option<&PageFocus>) { + let prev_sel = focused == Some(&PageFocus::NavPrev); + let next_sel = focused == Some(&PageFocus::NavNext); + + let prev = if prev_sel { "[<]" } else { " < " }; + let next = if next_sel { "[>]" } else { " > " }; + + // Build the line: --[<]-------[>]-- + let line = format!("--{}-------{}--", prev, next); + + f.render_widget( + Paragraph::new(line).style(ratatui::style::Style::default().white()), + area, + ); +}