feat: add display UI with splash screen, dashboard, and RSSI sparkline
Implement full SSD1306 OLED display support: - Splash screen on boot (1.5s) with project name, version, board - Dashboard showing radio state, config, counters, RSSI/SNR - RSSI history sparkline (64-sample ring buffer, 2px-wide bars) - DisplayOn/DisplayOff host commands routed via dedicated channel - Pure render functions against generic DrawTarget (no hardware coupling) - Display on/off control bypasses radio task entirely Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
17624 files changed
+4564928
-38
No files matched your search
+298
@@ -0,0 +1,298 @@
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use crate::error::ParserError;
|
||||
use crate::stream::Stream;
|
||||
use crate::{Parser, Result};
|
||||
|
||||
pub(crate) struct Trace<P, D, I, O, E>
|
||||
where
|
||||
P: Parser<I, O, E>,
|
||||
I: Stream,
|
||||
D: std::fmt::Display,
|
||||
E: ParserError<I>,
|
||||
{
|
||||
parser: P,
|
||||
name: D,
|
||||
call_count: usize,
|
||||
marker: core::marker::PhantomData<(I, O, E)>,
|
||||
}
|
||||
|
||||
impl<P, D, I, O, E> Trace<P, D, I, O, E>
|
||||
where
|
||||
P: Parser<I, O, E>,
|
||||
I: Stream,
|
||||
D: std::fmt::Display,
|
||||
E: ParserError<I>,
|
||||
{
|
||||
#[inline(always)]
|
||||
pub(crate) fn new(parser: P, name: D) -> Self {
|
||||
Self {
|
||||
parser,
|
||||
name,
|
||||
call_count: 0,
|
||||
marker: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<P, D, I, O, E> Parser<I, O, E> for Trace<P, D, I, O, E>
|
||||
where
|
||||
P: Parser<I, O, E>,
|
||||
I: Stream,
|
||||
D: std::fmt::Display,
|
||||
E: ParserError<I>,
|
||||
{
|
||||
#[inline]
|
||||
fn parse_next(&mut self, i: &mut I) -> Result<O, E> {
|
||||
let depth = Depth::new();
|
||||
let original = i.checkpoint();
|
||||
start(*depth, &self.name, self.call_count, i);
|
||||
|
||||
let res = self.parser.parse_next(i);
|
||||
|
||||
let consumed = i.offset_from(&original);
|
||||
let severity = Severity::with_result(&res);
|
||||
end(*depth, &self.name, self.call_count, consumed, severity);
|
||||
self.call_count += 1;
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Depth {
|
||||
depth: usize,
|
||||
inc: bool,
|
||||
}
|
||||
|
||||
impl Depth {
|
||||
pub(crate) fn new() -> Self {
|
||||
let depth = DEPTH.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let inc = true;
|
||||
Self { depth, inc }
|
||||
}
|
||||
|
||||
pub(crate) fn existing() -> Self {
|
||||
let depth = DEPTH.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let inc = false;
|
||||
Self { depth, inc }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Depth {
|
||||
fn drop(&mut self) {
|
||||
if self.inc {
|
||||
let _ = DEPTH.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<usize> for Depth {
|
||||
#[inline(always)]
|
||||
fn as_ref(&self) -> &usize {
|
||||
&self.depth
|
||||
}
|
||||
}
|
||||
|
||||
impl core::ops::Deref for Depth {
|
||||
type Target = usize;
|
||||
|
||||
#[inline(always)]
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.depth
|
||||
}
|
||||
}
|
||||
|
||||
static DEPTH: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
pub(crate) enum Severity {
|
||||
Success,
|
||||
Backtrack,
|
||||
Cut,
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
impl Severity {
|
||||
pub(crate) fn with_result<T, I: Stream, E: ParserError<I>>(result: &Result<T, E>) -> Self {
|
||||
match result {
|
||||
Ok(_) => Self::Success,
|
||||
Err(e) if e.is_backtrack() => Self::Backtrack,
|
||||
Err(e) if e.is_incomplete() => Self::Incomplete,
|
||||
_ => Self::Cut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start<I: Stream>(
|
||||
depth: usize,
|
||||
name: &dyn core::fmt::Display,
|
||||
count: usize,
|
||||
input: &I,
|
||||
) {
|
||||
let gutter_style = anstyle::Style::new().bold();
|
||||
let input_style = anstyle::Style::new().underline();
|
||||
let eof_style = anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Cyan.into()));
|
||||
|
||||
let (call_width, input_width) = column_widths();
|
||||
|
||||
let count = if 0 < count {
|
||||
format!(":{count}")
|
||||
} else {
|
||||
"".to_owned()
|
||||
};
|
||||
let call_column = format!("{:depth$}> {name}{count}", "");
|
||||
|
||||
// The debug version of `slice` might be wider, either due to rendering one byte as two nibbles or
|
||||
// escaping in strings.
|
||||
let mut debug_slice = format!("{:?}", crate::util::from_fn(|f| input.trace(f)));
|
||||
let (debug_slice, eof) = if let Some(debug_offset) = debug_slice
|
||||
.char_indices()
|
||||
.enumerate()
|
||||
.find_map(|(pos, (offset, _))| (input_width <= pos).then_some(offset))
|
||||
{
|
||||
debug_slice.truncate(debug_offset);
|
||||
let eof = "";
|
||||
(debug_slice, eof)
|
||||
} else {
|
||||
let eof = if debug_slice.chars().count() < input_width {
|
||||
"∅"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
(debug_slice, eof)
|
||||
};
|
||||
|
||||
let writer = anstream::stderr();
|
||||
let mut writer = writer.lock();
|
||||
let _ = writeln!(
|
||||
writer,
|
||||
"{call_column:call_width$} {gutter_style}|{gutter_reset} {input_style}{debug_slice}{input_reset}{eof_style}{eof}{eof_reset}",
|
||||
gutter_style=gutter_style.render(),
|
||||
gutter_reset=gutter_style.render_reset(),
|
||||
input_style=input_style.render(),
|
||||
input_reset=input_style.render_reset(),
|
||||
eof_style=eof_style.render(),
|
||||
eof_reset=eof_style.render_reset(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn end(
|
||||
depth: usize,
|
||||
name: &dyn core::fmt::Display,
|
||||
count: usize,
|
||||
consumed: usize,
|
||||
severity: Severity,
|
||||
) {
|
||||
let gutter_style = anstyle::Style::new().bold();
|
||||
|
||||
let (call_width, _) = column_widths();
|
||||
|
||||
let count = if 0 < count {
|
||||
format!(":{count}")
|
||||
} else {
|
||||
"".to_owned()
|
||||
};
|
||||
let call_column = format!("{:depth$}< {name}{count}", "");
|
||||
|
||||
let (status_style, status) = match severity {
|
||||
Severity::Success => {
|
||||
let style = anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Green.into()));
|
||||
let status = format!("+{consumed}");
|
||||
(style, status)
|
||||
}
|
||||
Severity::Backtrack => (
|
||||
anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Yellow.into())),
|
||||
"backtrack".to_owned(),
|
||||
),
|
||||
Severity::Cut => (
|
||||
anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Red.into())),
|
||||
"cut".to_owned(),
|
||||
),
|
||||
Severity::Incomplete => (
|
||||
anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Red.into())),
|
||||
"incomplete".to_owned(),
|
||||
),
|
||||
};
|
||||
|
||||
let writer = anstream::stderr();
|
||||
let mut writer = writer.lock();
|
||||
let _ = writeln!(
|
||||
writer,
|
||||
"{status_style}{call_column:call_width$}{status_reset} {gutter_style}|{gutter_reset} {status_style}{status}{status_reset}",
|
||||
gutter_style=gutter_style.render(),
|
||||
gutter_reset=gutter_style.render_reset(),
|
||||
status_style=status_style.render(),
|
||||
status_reset=status_style.render_reset(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn result(depth: usize, name: &dyn core::fmt::Display, severity: Severity) {
|
||||
let gutter_style = anstyle::Style::new().bold();
|
||||
|
||||
let (call_width, _) = column_widths();
|
||||
|
||||
let call_column = format!("{:depth$}| {name}", "");
|
||||
|
||||
let (status_style, status) = match severity {
|
||||
Severity::Success => (
|
||||
anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Green.into())),
|
||||
"",
|
||||
),
|
||||
Severity::Backtrack => (
|
||||
anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Yellow.into())),
|
||||
"backtrack",
|
||||
),
|
||||
Severity::Cut => (
|
||||
anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Red.into())),
|
||||
"cut",
|
||||
),
|
||||
Severity::Incomplete => (
|
||||
anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Red.into())),
|
||||
"incomplete",
|
||||
),
|
||||
};
|
||||
|
||||
let writer = anstream::stderr();
|
||||
let mut writer = writer.lock();
|
||||
let _ = writeln!(
|
||||
writer,
|
||||
"{status_style}{call_column:call_width$}{status_reset} {gutter_style}|{gutter_reset} {status_style}{status}{status_reset}",
|
||||
gutter_style=gutter_style.render(),
|
||||
gutter_reset=gutter_style.render_reset(),
|
||||
status_style=status_style.render(),
|
||||
status_reset=status_style.render_reset(),
|
||||
);
|
||||
}
|
||||
|
||||
fn column_widths() -> (usize, usize) {
|
||||
let term_width = term_width();
|
||||
|
||||
let min_call_width = 40;
|
||||
let min_input_width = 20;
|
||||
let decor_width = 3;
|
||||
let extra_width = term_width.saturating_sub(min_call_width + min_input_width + decor_width);
|
||||
let call_width = min_call_width + 2 * extra_width / 3;
|
||||
let input_width = min_input_width + extra_width / 3;
|
||||
|
||||
(call_width, input_width)
|
||||
}
|
||||
|
||||
fn term_width() -> usize {
|
||||
columns_env().or_else(query_width).unwrap_or(80)
|
||||
}
|
||||
|
||||
fn query_width() -> Option<usize> {
|
||||
use is_terminal_polyfill::IsTerminal;
|
||||
if std::io::stderr().is_terminal() {
|
||||
terminal_size::terminal_size().map(|(w, _h)| w.0.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn columns_env() -> Option<usize> {
|
||||
std::env::var("COLUMNS")
|
||||
.ok()
|
||||
.and_then(|c| c.parse::<usize>().ok())
|
||||
}
|
||||
Reference in new issue
Block a user