better ui with 3 lines now

This commit is contained in:
Priec
2026-01-18 17:09:42 +01:00
parent c5fef061f4
commit a910015856
5 changed files with 175 additions and 175 deletions

View File

@@ -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",

View File

@@ -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>),
}

View File

@@ -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<CriticalSectionRawMutex, DisplayCommand, QUEUE_SIZE> =
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;
}

View File

@@ -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<DisplaySize128x32>>| {
flush_callback: Box::new(|d: &mut Ssd1306<_, _, BufferedGraphicsMode<DisplaySize128x32>>| {
let _ = d.flush();
},
),
}),
..Default::default()
};
@@ -46,47 +42,43 @@ pub async fn display_task(i2c: I2cDevice) {
let mut orchestrator = Orchestrator::<Screen>::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;

View File

@@ -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<ImuReading>,
pub(crate) last_error: Option<HString<64>>,
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<Option<Self::Event>, 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<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
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,
);
}