use std::collections::VecDeque;
use std::f64::consts::PI;
use std::io;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Layout, Margin, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{
Axis, Block, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, ListState,
Paragraph,
},
Frame, Terminal,
};
use tokio::sync::{mpsc, oneshot};
use muse_rs::muse_client::{MuseClient, MuseClientConfig, MuseDevice, MuseHandle};
use muse_rs::protocol::EEG_CHANNEL_NAMES;
use muse_rs::types::MuseEvent;
const WINDOW_SECS: f64 = 2.0;
const EEG_HZ: f64 = 256.0;
const BUF_SIZE: usize = (WINDOW_SECS * EEG_HZ) as usize;
const NUM_CH: usize = 4;
const PPG_HZ: f64 = 64.0;
const PPG_BUF_SIZE: usize = (WINDOW_SECS * PPG_HZ) as usize;
const PPG_NUM_CH: usize = 3;
const PPG_CHANNEL_NAMES: [&str; 3] = ["Ambient", "Infrared", "Red"];
const PPG_COLORS: [Color; 3] = [Color::LightBlue, Color::LightRed, Color::Red];
const Y_SCALES: &[f64] = &[10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0];
const DEFAULT_SCALE: usize = 5;
const COLORS: [Color; 4] = [Color::Cyan, Color::Yellow, Color::Green, Color::Magenta];
const DIM_COLORS: [Color; 4] = [
Color::Rgb(0, 90, 110), Color::Rgb(110, 90, 0), Color::Rgb(0, 110, 0), Color::Rgb(110, 0, 110), ];
const SMOOTH_WINDOW: usize = 9;
const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const RETRY_SECS: u64 = 6;
const RECONNECT_DELAY_SECS: u64 = 2;
#[derive(Clone)]
pub enum AppMode {
Scanning,
Connecting(String),
Connected { name: String, id: String },
Simulated,
NoDevices,
Disconnected,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Eeg,
Ppg,
}
pub struct App {
bufs: [VecDeque<f64>; NUM_CH],
ppg_bufs: [VecDeque<f64>; PPG_NUM_CH],
pub view: ViewMode,
pub mode: AppMode,
pub battery: Option<f32>,
pub accel: Option<(f32, f32, f32)>,
pub gyro: Option<(f32, f32, f32)>,
total_samples: u64,
pkt_times: VecDeque<Instant>,
scale_idx: usize,
pub paused: bool,
pub show_picker: bool,
pub picker_cursor: usize,
pub picker_entries: Vec<String>,
pub picker_connected_idx: Option<usize>,
pub picker_scanning: bool,
pub last_error: Option<String>,
pub smooth: bool,
}
impl App {
fn new() -> Self {
Self {
bufs: std::array::from_fn(|_| VecDeque::with_capacity(BUF_SIZE + 16)),
ppg_bufs: std::array::from_fn(|_| VecDeque::with_capacity(PPG_BUF_SIZE + 16)),
view: ViewMode::Eeg,
mode: AppMode::Scanning,
battery: None,
accel: None,
gyro: None,
total_samples: 0,
pkt_times: VecDeque::with_capacity(256),
scale_idx: DEFAULT_SCALE,
paused: false,
show_picker: false,
picker_cursor: 0,
picker_entries: vec![],
picker_connected_idx: None,
picker_scanning: false,
last_error: None,
smooth: true,
}
}
pub fn push(&mut self, ch: usize, samples: &[f64]) {
if self.paused || ch >= NUM_CH {
return;
}
let buf = &mut self.bufs[ch];
for &v in samples {
buf.push_back(v);
while buf.len() > BUF_SIZE {
buf.pop_front();
}
}
if ch == 0 {
self.total_samples += samples.len() as u64;
let now = Instant::now();
self.pkt_times.push_back(now);
while self
.pkt_times
.front()
.map(|t| now.duration_since(*t) > Duration::from_secs(2))
.unwrap_or(false)
{
self.pkt_times.pop_front();
}
}
}
pub fn push_ppg(&mut self, ch: usize, samples: &[u32]) {
if self.paused || ch >= PPG_NUM_CH {
return;
}
let buf = &mut self.ppg_bufs[ch];
for &v in samples {
buf.push_back(v as f64);
while buf.len() > PPG_BUF_SIZE {
buf.pop_front();
}
}
}
pub fn clear(&mut self) {
for b in &mut self.bufs {
b.clear();
}
for b in &mut self.ppg_bufs {
b.clear();
}
self.total_samples = 0;
self.pkt_times.clear();
self.battery = None;
self.accel = None;
self.gyro = None;
self.last_error = None;
}
fn pkt_rate(&self) -> f64 {
let n = self.pkt_times.len();
if n < 2 {
return 0.0;
}
let span = self
.pkt_times
.back()
.unwrap()
.duration_since(self.pkt_times[0])
.as_secs_f64();
if span < 1e-9 {
0.0
} else {
(n as f64 - 1.0) / span
}
}
fn y_range(&self) -> f64 {
Y_SCALES[self.scale_idx]
}
fn scale_up(&mut self) {
if self.scale_idx + 1 < Y_SCALES.len() {
self.scale_idx += 1;
}
}
fn scale_down(&mut self) {
if self.scale_idx > 0 {
self.scale_idx -= 1;
}
}
fn auto_scale(&mut self) {
let peak = self
.bufs
.iter()
.flat_map(|b| b.iter())
.fold(0.0_f64, |acc, &v| acc.max(v.abs()));
let needed = peak * 1.1;
self.scale_idx = Y_SCALES
.iter()
.position(|&s| s >= needed)
.unwrap_or(Y_SCALES.len() - 1);
}
}
fn short_id(id: &str) -> String {
let trimmed = id.trim_matches(|c: char| c == '{' || c == '}');
if trimmed.len() > 8 {
trimmed[trimmed.len() - 8..].to_uppercase()
} else {
trimmed.to_uppercase()
}
}
fn device_entry(d: &MuseDevice) -> String {
format!("{} [{}]", d.name, short_id(&d.id))
}
fn smooth_signal(data: &[(f64, f64)], window: usize) -> Vec<(f64, f64)> {
if data.len() < 3 || window < 2 {
return data.to_vec();
}
let half = window / 2;
data.iter()
.enumerate()
.map(|(i, &(x, _))| {
let start = i.saturating_sub(half);
let end = (i + half + 1).min(data.len());
let sum: f64 = data[start..end].iter().map(|&(_, y)| y).sum();
(x, sum / (end - start) as f64)
})
.collect()
}
fn sim_sample(t: f64, ch: usize) -> f64 {
let phi = ch as f64 * PI / 2.5;
let alpha = 20.0 * (2.0 * PI * 10.0 * t + phi).sin();
let beta = 6.0 * (2.0 * PI * 22.0 * t + phi * 1.7).sin();
let theta = 10.0 * (2.0 * PI * 6.0 * t + phi * 0.9).sin();
let nx = t * 1000.7 + ch as f64 * 137.508;
let noise = ((nx.sin() * 9973.1).fract() - 0.5) * 8.0;
alpha + beta + theta + noise
}
fn spawn_simulator(app: Arc<Mutex<App>>) {
tokio::spawn(async move {
let pkt_interval = Duration::from_secs_f64(12.0 / EEG_HZ);
let mut ticker = tokio::time::interval(pkt_interval);
let dt = 1.0 / EEG_HZ;
let mut t = 0.0_f64;
let mut seq = 0u32;
loop {
ticker.tick().await;
let mut s = app.lock().unwrap();
if s.paused {
t += 12.0 * dt;
continue;
}
for ch in 0..NUM_CH {
let samples: Vec<f64> =
(0..12).map(|i| sim_sample(t + i as f64 * dt, ch)).collect();
s.push(ch, &samples);
}
seq = seq.wrapping_add(1);
if seq.is_multiple_of(21) {
s.battery = Some((85.0 - t as f32 / 300.0).clamp(0.0, 100.0));
s.accel = Some((
(0.01 * (2.0 * PI * 0.3 * t).sin()) as f32,
(0.02 * (2.0 * PI * 0.5 * t).cos()) as f32,
(-1.0 + 0.005 * (2.0 * PI * 0.1 * t).sin()) as f32,
));
s.gyro = Some((
(0.12 * (2.0 * PI * 0.2 * t).sin()) as f32,
(0.08 * (2.0 * PI * 0.3 * t).cos()) as f32,
(0.05 * (2.0 * PI * 0.1 * t).sin()) as f32,
));
}
t += 12.0 * dt;
}
});
}
struct ScanResult {
devices: Vec<MuseDevice>,
error: Option<String>,
}
fn start_scan(config: MuseClientConfig) -> oneshot::Receiver<ScanResult> {
let (tx, rx) = oneshot::channel();
let deadline = Duration::from_secs(config.scan_timeout_secs + 10);
tokio::spawn(async move {
let result = match tokio::time::timeout(deadline, MuseClient::new(config).scan_all()).await
{
Ok(Ok(devices)) => {
log::info!("Scan completed: {} device(s) found", devices.len());
ScanResult { devices, error: None }
}
Ok(Err(e)) => {
log::error!("Scan failed: {e}");
ScanResult { devices: vec![], error: Some(format!("{e}")) }
}
Err(_) => {
log::error!("Scan timed out after {deadline:?}");
ScanResult { devices: vec![], error: Some("scan timed out".into()) }
}
};
let _ = tx.send(result);
});
rx
}
fn restart_scan(
app: &Arc<Mutex<App>>,
pending_scan: &mut Option<oneshot::Receiver<ScanResult>>,
retry_at: &mut Option<tokio::time::Instant>,
delay_secs: u64,
) {
{
let mut s = app.lock().unwrap();
s.clear();
s.picker_connected_idx = None;
s.picker_entries.clear(); s.show_picker = false;
s.mode = AppMode::Scanning;
s.picker_scanning = true;
}
if pending_scan.is_none() {
*retry_at = Some(tokio::time::Instant::now() + Duration::from_secs(delay_secs));
}
}
fn spawn_event_task(mut rx: tokio::sync::mpsc::Receiver<MuseEvent>, app: Arc<Mutex<App>>) {
tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
let mut s = app.lock().unwrap();
match ev {
MuseEvent::Connected(_) => {}
MuseEvent::Disconnected => {
s.mode = AppMode::Disconnected;
s.picker_connected_idx = None;
break;
}
MuseEvent::Eeg(r) if r.electrode < NUM_CH => {
s.push(r.electrode, &r.samples);
}
MuseEvent::Ppg(r) if r.ppg_channel < PPG_NUM_CH => {
s.push_ppg(r.ppg_channel, &r.samples);
}
MuseEvent::Telemetry(t) => {
s.battery = Some(t.battery_level);
}
MuseEvent::Accelerometer(a) => {
let v = a.samples[0];
s.accel = Some((v.x, v.y, v.z));
}
MuseEvent::Gyroscope(g) => {
let v = g.samples[0];
s.gyro = Some((v.x, v.y, v.z));
}
_ => {}
}
}
});
}
struct ConnectOutcome {
rx: mpsc::Receiver<MuseEvent>,
handle: MuseHandle,
device_idx: usize,
name: String,
id: String,
}
fn start_connect(
idx: usize,
device: MuseDevice,
app: Arc<Mutex<App>>,
scan_cfg: MuseClientConfig,
) -> oneshot::Receiver<Option<ConnectOutcome>> {
let (tx, rx) = oneshot::channel();
{
let mut s = app.lock().unwrap();
s.clear();
s.mode = AppMode::Connecting(device.name.clone());
s.picker_connected_idx = None;
s.show_picker = false;
}
tokio::spawn(async move {
let client = MuseClient::new(scan_cfg);
match client.connect_to(device.clone()).await {
Ok((evt_rx, h)) => {
match h.start(false, false).await {
Ok(()) => {
let _ = tx.send(Some(ConnectOutcome {
rx: evt_rx,
handle: h,
device_idx: idx,
name: device.name.clone(),
id: short_id(&device.id),
}));
}
Err(e) => {
log::warn!("start() failed: {e}");
let _ = h.disconnect().await;
let mut s = app.lock().unwrap();
s.mode = AppMode::Disconnected;
s.last_error = Some(format!("start() failed: {e}"));
let _ = tx.send(None);
}
}
}
Err(e) => {
log::warn!("connect_to failed: {e}");
let mut s = app.lock().unwrap();
s.mode = AppMode::Disconnected;
s.last_error = Some(format!("connect_to failed: {e}"));
let _ = tx.send(None);
}
}
});
rx
}
fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
let root = Layout::vertical([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(area);
draw_header(frame, root[0], app);
draw_charts(frame, root[1], app);
draw_footer(frame, root[2], app);
if app.show_picker {
draw_device_picker(frame, area, app);
}
}
fn spinner_str() -> &'static str {
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
SPINNER[(ms / 100) as usize % SPINNER.len()]
}
fn draw_header(frame: &mut Frame, area: Rect, app: &App) {
let (label, color) = match &app.mode {
AppMode::Scanning => (format!("{} Scanning…", spinner_str()), Color::Yellow),
AppMode::Connecting(name) => (
format!("{} Connecting to {}…", spinner_str(), name),
Color::Yellow,
),
AppMode::Connected { name, id } => {
(format!("● {} [{}]", name, id), Color::Green)
}
AppMode::Simulated => ("◆ Simulated".to_owned(), Color::Cyan),
AppMode::NoDevices => (
format!("{} No devices found — retrying…", spinner_str()),
Color::Yellow,
),
AppMode::Disconnected => {
let reason = app
.last_error
.as_deref()
.map(|e| format!(" ({e})"))
.unwrap_or_default();
(
format!("{} Disconnected{reason} — retrying…", spinner_str()),
Color::Red,
)
}
};
let bat = app
.battery
.map(|b| format!("Bat {b:.0}%"))
.unwrap_or_else(|| "Bat N/A".into());
let rate = format!("{:.1} pkt/s", app.pkt_rate());
let scale = match app.view {
ViewMode::Eeg => format!("±{:.0} µV", app.y_range()),
ViewMode::Ppg => "auto".to_string(),
};
let view_label = match app.view {
ViewMode::Eeg => "EEG",
ViewMode::Ppg => "PPG",
};
let total = format!("{}K smp", app.total_samples / 1_000);
let line = Line::from(vec![
Span::styled(
" MUSE EEG Monitor ",
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
sep(),
Span::styled(label, Style::default().fg(color).add_modifier(Modifier::BOLD)),
sep(),
Span::styled(
view_label,
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD),
),
sep(),
Span::styled(bat, Style::default().fg(Color::White)),
sep(),
Span::styled(rate, Style::default().fg(Color::White)),
sep(),
Span::styled(
scale,
Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD),
),
sep(),
Span::styled(total, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
]);
frame.render_widget(
Paragraph::new(line).block(Block::default().borders(Borders::ALL)),
area,
);
}
#[inline]
fn sep<'a>() -> Span<'a> {
Span::styled(" │ ", Style::default().fg(Color::DarkGray))
}
fn draw_charts(frame: &mut Frame, area: Rect, app: &App) {
match app.view {
ViewMode::Eeg => draw_eeg_charts(frame, area, app),
ViewMode::Ppg => draw_ppg_charts(frame, area, app),
}
}
fn draw_eeg_charts(frame: &mut Frame, area: Rect, app: &App) {
let rows = Layout::vertical([
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
])
.split(area);
let y_range = app.y_range();
let data: Vec<Vec<(f64, f64)>> = (0..NUM_CH)
.map(|ch| {
app.bufs[ch]
.iter()
.enumerate()
.map(|(i, &v)| (i as f64 / EEG_HZ, v.clamp(-y_range, y_range)))
.collect()
})
.collect();
for ch in 0..NUM_CH {
draw_channel(frame, rows[ch], ch, &data[ch], app);
}
}
fn draw_ppg_charts(frame: &mut Frame, area: Rect, app: &App) {
let rows = Layout::vertical([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(area);
for ch in 0..PPG_NUM_CH {
draw_ppg_channel(frame, rows[ch], ch, app);
}
}
fn draw_ppg_channel(frame: &mut Frame, area: Rect, ch: usize, app: &App) {
let color = PPG_COLORS[ch];
let name = PPG_CHANNEL_NAMES[ch];
let buf = &app.ppg_bufs[ch];
let (min_v, max_v) = if buf.is_empty() {
(0.0_f64, 1.0_f64)
} else {
let min = buf.iter().copied().fold(f64::INFINITY, f64::min);
let max = buf.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let margin = (max - min).max(1.0) * 0.05;
(min - margin, max + margin)
};
let data: Vec<(f64, f64)> = buf
.iter()
.enumerate()
.map(|(i, &v)| (i as f64 / PPG_HZ, v))
.collect();
let smoothed: Vec<(f64, f64)> = if app.smooth {
smooth_signal(&data, SMOOTH_WINDOW)
} else {
vec![]
};
let dim_color = match ch {
0 => Color::Rgb(0, 60, 90), 1 => Color::Rgb(90, 40, 40), _ => Color::Rgb(90, 0, 0), };
let datasets: Vec<Dataset> = if app.smooth {
vec![
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(dim_color))
.data(&data),
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(color))
.data(&smoothed),
]
} else {
vec![Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(color))
.data(&data)]
};
let smooth_tag = if app.smooth { " [SMOOTH]" } else { "" };
let title = format!(
" {name} min:{min_v:.0} max:{max_v:.0}{smooth_tag} "
);
let y_mid = (min_v + max_v) / 2.0;
let y_labels: Vec<String> = vec![
format!("{:.0}", min_v),
format!("{:.0}", y_mid),
format!("{:.0}", max_v),
];
let x_labels = vec![
"0s".to_string(),
format!("{:.1}s", WINDOW_SECS / 2.0),
format!("{:.0}s", WINDOW_SECS),
];
let chart = Chart::new(datasets)
.block(
Block::default()
.title(Span::styled(
title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(color)),
)
.x_axis(
Axis::default()
.bounds([0.0, WINDOW_SECS])
.labels(x_labels)
.style(Style::default().fg(Color::DarkGray)),
)
.y_axis(
Axis::default()
.bounds([min_v, max_v])
.labels(y_labels)
.style(Style::default().fg(Color::DarkGray)),
);
frame.render_widget(chart, area);
}
fn draw_channel(frame: &mut Frame, area: Rect, ch: usize, data: &[(f64, f64)], app: &App) {
let color = COLORS[ch];
let y_range = app.y_range();
let name = EEG_CHANNEL_NAMES[ch];
let (min_v, max_v, rms_v) = {
let buf = &app.bufs[ch];
if buf.is_empty() {
(0.0_f64, 0.0_f64, 0.0_f64)
} else {
let min = buf.iter().copied().fold(f64::INFINITY, f64::min);
let max = buf.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let rms = (buf.iter().map(|&v| v * v).sum::<f64>() / buf.len() as f64).sqrt();
(min, max, rms)
}
};
let clipping = max_v > y_range || min_v < -y_range;
let border_color = if clipping { Color::Red } else { color };
let clip_tag = if clipping { " [CLIP +]" } else { "" };
let smooth_tag = if app.smooth { " [SMOOTH]" } else { "" };
let title = format!(
" {name} min:{min_v:+6.1} max:{max_v:+6.1} rms:{rms_v:5.1} µV{clip_tag}{smooth_tag} "
);
let y_labels: Vec<String> = [-1.0, -0.5, 0.0, 0.5, 1.0]
.iter()
.map(|&f| format!("{:+.0}", f * y_range))
.collect();
let x_labels = vec![
"0s".to_string(),
format!("{:.1}s", WINDOW_SECS / 2.0),
format!("{:.0}s", WINDOW_SECS),
];
let smoothed: Vec<(f64, f64)> = if app.smooth {
smooth_signal(data, SMOOTH_WINDOW)
} else {
vec![]
};
let datasets: Vec<Dataset> = if app.smooth {
vec![
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(DIM_COLORS[ch]))
.data(data),
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(color))
.data(&smoothed),
]
} else {
vec![Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(color))
.data(data)]
};
let chart = Chart::new(datasets)
.block(
Block::default()
.title(Span::styled(
title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.x_axis(
Axis::default()
.bounds([0.0, WINDOW_SECS])
.labels(x_labels)
.style(Style::default().fg(Color::DarkGray)),
)
.y_axis(
Axis::default()
.bounds([-y_range, y_range])
.labels(y_labels)
.style(Style::default().fg(Color::DarkGray)),
);
frame.render_widget(chart, area);
}
fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
let pause_span = if app.paused {
Span::styled(
" ⏸ PAUSED",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else {
Span::raw("")
};
let keys = Line::from(vec![
Span::raw(" "),
key("[Tab]"),
Span::raw("Devices "),
key("[1]"),
Span::raw("EEG "),
key("[2]"),
Span::raw("PPG "),
key("[d]"),
Span::raw("Disconnect "),
key("[+/-]"),
Span::raw("Scale "),
key("[a]"),
Span::raw("Auto "),
key("[v]"),
Span::raw(if app.smooth { "Raw " } else { "Smooth " }),
key("[p]"),
Span::raw("Pause "),
key("[r]"),
Span::raw("Resume "),
key("[c]"),
Span::raw("Clear "),
key("[q]"),
Span::raw("Quit"),
pause_span,
]);
let second_line = match &app.mode {
AppMode::NoDevices => {
let base = if cfg!(target_os = "macos") {
" No Muse found. On macOS grant Bluetooth access: System Settings → Privacy & Security → Bluetooth."
} else {
" No Muse found. Make sure the headset is powered on and in range."
};
let detail = app
.last_error
.as_deref()
.map(|e| format!(" Error: {e}"))
.unwrap_or_default();
Line::from(Span::styled(
format!("{base}{detail} Retrying…"),
Style::default().fg(Color::Yellow),
))
}
_ => {
let (ax, ay, az) = app.accel.unwrap_or((0.0, 0.0, 0.0));
let (gx, gy, gz) = app.gyro.unwrap_or((0.0, 0.0, 0.0));
Line::from(vec![
Span::raw(" "),
Span::styled("Accel ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("x:{ax:+.3}g y:{ay:+.3}g z:{az:+.3}g"),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled("Gyro ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("x:{gx:+.3}°/s y:{gy:+.3}°/s z:{gz:+.3}°/s"),
Style::default().fg(Color::Magenta),
),
])
}
};
frame.render_widget(
Paragraph::new(vec![keys, second_line]).block(Block::default().borders(Borders::ALL)),
area,
);
}
#[inline]
fn key(s: &str) -> Span<'_> {
Span::styled(
s,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
}
fn draw_device_picker(frame: &mut Frame, area: Rect, app: &App) {
let n = app.picker_entries.len().max(1);
let inner_h = n as u16 + 4;
let box_h = inner_h + 2;
let box_w = (area.width * 60 / 100).max(52).min(area.width);
let x = area.x + (area.width.saturating_sub(box_w)) / 2;
let y = area.y + (area.height.saturating_sub(box_h)) / 2;
let popup = Rect::new(x, y, box_w, box_h.min(area.height));
frame.render_widget(Clear, popup);
let title = if app.picker_scanning {
format!(" {} Scanning… ({} found) ", spinner_str(), app.picker_entries.len())
} else {
format!(" Select Device ({} found) ", app.picker_entries.len())
};
frame.render_widget(
Block::default()
.title(Span::styled(
title,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White)),
popup,
);
let inner = popup.inner(Margin {
horizontal: 1,
vertical: 1,
});
let hint_h = 2u16;
let [list_area, _, hint_area] = Layout::vertical([
Constraint::Length(inner.height.saturating_sub(hint_h + 1)),
Constraint::Length(1),
Constraint::Length(hint_h),
])
.areas(inner);
let items: Vec<ListItem> = if app.picker_entries.is_empty() {
vec![ListItem::new(Span::styled(
" No devices found — press [s] to scan",
Style::default().fg(Color::DarkGray),
))]
} else {
app.picker_entries
.iter()
.enumerate()
.map(|(i, entry)| {
let connected = app.picker_connected_idx == Some(i);
let (bullet, color, suffix) = if connected {
("● ", Color::Green, " ← connected")
} else {
(" ", Color::White, "")
};
ListItem::new(Span::styled(
format!("{bullet}{entry}{suffix}"),
Style::default().fg(color),
))
})
.collect()
};
let mut list_state = ListState::default();
if !app.picker_entries.is_empty() {
list_state.select(Some(app.picker_cursor));
}
frame.render_stateful_widget(
List::new(items)
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ "),
list_area,
&mut list_state,
);
frame.render_widget(
Paragraph::new(vec![
Line::from(vec![
key(" [↑↓]"),
Span::raw(" Navigate "),
key("[↵]"),
Span::raw(" Connect "),
key("[s]"),
Span::raw(" Rescan "),
key("[Esc]"),
Span::raw(" Close"),
]),
Line::from(Span::styled(
" Device list is refreshed after every scan",
Style::default().fg(Color::DarkGray),
)),
]),
hint_area,
);
}
#[tokio::main]
async fn main() -> Result<()> {
use std::io::IsTerminal as _;
if !io::stdout().is_terminal() {
eprintln!("Error: muse-rs tui requires a real terminal (TTY).");
eprintln!("Run it directly in a terminal emulator, not piped or redirected.");
std::process::exit(1);
}
{
use std::fs::File;
if let Ok(file) = File::create("muse-tui.log") {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.target(env_logger::Target::Pipe(Box::new(file)))
.init();
}
}
let simulate = std::env::args().any(|a| a == "--simulate");
let app = Arc::new(Mutex::new(App::new()));
let mut devices: Vec<MuseDevice> = vec![];
let mut connected_idx: Option<usize> = None;
let mut handle: Option<Arc<MuseHandle>> = None;
let mut pending_scan: Option<oneshot::Receiver<ScanResult>> = None;
let mut pending_connect: Option<oneshot::Receiver<Option<ConnectOutcome>>> = None;
let mut retry_at: Option<tokio::time::Instant> = None;
let scan_cfg = MuseClientConfig {
scan_timeout_secs: 5,
..Default::default()
};
if simulate {
let mut s = app.lock().unwrap();
s.mode = AppMode::Simulated;
s.scale_idx = 2; drop(s);
spawn_simulator(Arc::clone(&app));
} else {
app.lock().unwrap().picker_scanning = true;
pending_scan = Some(start_scan(scan_cfg.clone()));
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;
let tick = Duration::from_millis(33);
'main: loop {
if let Some(ref mut rx) = pending_scan {
if let Ok(scan_result) = rx.try_recv() {
pending_scan = None;
devices = scan_result.devices;
{
let mut s = app.lock().unwrap();
s.picker_entries = devices.iter().map(device_entry).collect();
s.picker_scanning = false;
if devices.is_empty() {
s.mode = AppMode::NoDevices;
if let Some(err) = scan_result.error {
s.last_error = Some(err);
}
}
}
if devices.is_empty() {
retry_at = Some(
tokio::time::Instant::now() + Duration::from_secs(RETRY_SECS),
);
} else if handle.is_none() && pending_connect.is_none() {
pending_connect = Some(start_connect(
0,
devices[0].clone(),
Arc::clone(&app),
scan_cfg.clone(),
));
}
}
}
if let Some(ref mut rx) = pending_connect {
if let Ok(result) = rx.try_recv() {
pending_connect = None;
if let Some(outcome) = result {
let h = Arc::new(outcome.handle);
{
let mut s = app.lock().unwrap();
s.mode = AppMode::Connected {
name: outcome.name,
id: outcome.id,
};
s.last_error = None;
s.picker_connected_idx = Some(outcome.device_idx);
}
connected_idx = Some(outcome.device_idx);
handle = Some(Arc::clone(&h));
spawn_event_task(outcome.rx, Arc::clone(&app));
} else {
connected_idx = None;
devices.clear(); restart_scan(&app, &mut pending_scan, &mut retry_at, RECONNECT_DELAY_SECS);
}
}
}
{
let is_disconnected =
matches!(app.lock().unwrap().mode, AppMode::Disconnected);
if is_disconnected && handle.is_some() {
if let Some(h) = handle.take() {
tokio::spawn(async move { let _ = h.disconnect().await; });
}
connected_idx = None;
devices.clear(); restart_scan(&app, &mut pending_scan, &mut retry_at, RECONNECT_DELAY_SECS);
}
}
if let Some(t) = retry_at {
if tokio::time::Instant::now() >= t && pending_scan.is_none() {
retry_at = None;
app.lock().unwrap().mode = AppMode::Scanning;
app.lock().unwrap().picker_scanning = true;
pending_scan = Some(start_scan(scan_cfg.clone()));
}
}
{
let s = app.lock().unwrap();
terminal.draw(|f| draw(f, &s))?;
}
if !event::poll(tick)? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
let ctrl_c = key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('c');
if key.code == KeyCode::Char('q') || ctrl_c {
break 'main;
}
if app.lock().unwrap().show_picker {
match key.code {
KeyCode::Esc => {
app.lock().unwrap().show_picker = false;
}
KeyCode::Char('s') => {
if pending_scan.is_none() {
retry_at = None;
let mut s = app.lock().unwrap();
s.mode = AppMode::Scanning;
s.picker_scanning = true;
drop(s);
pending_scan = Some(start_scan(scan_cfg.clone()));
}
}
KeyCode::Up => {
let mut s = app.lock().unwrap();
if s.picker_cursor > 0 {
s.picker_cursor -= 1;
}
}
KeyCode::Down => {
let mut s = app.lock().unwrap();
let max = s.picker_entries.len().saturating_sub(1);
if s.picker_cursor < max {
s.picker_cursor += 1;
}
}
KeyCode::Enter => {
let (cursor, n) = {
let s = app.lock().unwrap();
(s.picker_cursor, s.picker_entries.len())
};
if n > 0 && cursor < n && cursor < devices.len() {
retry_at = None;
if let Some(h) = handle.take() {
tokio::spawn(async move { let _ = h.disconnect().await; });
}
connected_idx = None;
pending_connect = Some(start_connect(
cursor,
devices[cursor].clone(),
Arc::clone(&app),
scan_cfg.clone(),
));
}
}
_ => {}
}
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break 'main,
KeyCode::Tab => {
let mut s = app.lock().unwrap();
s.show_picker = true;
if let Some(ci) = connected_idx {
s.picker_cursor = ci;
}
}
KeyCode::Char('s') => {
if pending_scan.is_none() {
retry_at = None;
app.lock().unwrap().mode = AppMode::Scanning;
app.lock().unwrap().picker_scanning = true;
pending_scan = Some(start_scan(scan_cfg.clone()));
}
}
KeyCode::Char('d') => {
if let Some(h) = handle.take() {
tokio::spawn(async move { let _ = h.disconnect().await; });
}
pending_connect = None;
connected_idx = None;
app.lock().unwrap().picker_connected_idx = None;
if pending_scan.is_none() {
retry_at = None;
app.lock().unwrap().mode = AppMode::Scanning;
app.lock().unwrap().picker_scanning = true;
pending_scan = Some(start_scan(scan_cfg.clone()));
}
}
KeyCode::Char('+') | KeyCode::Char('=') => {
app.lock().unwrap().scale_up();
}
KeyCode::Char('-') => {
app.lock().unwrap().scale_down();
}
KeyCode::Char('a') => {
app.lock().unwrap().auto_scale();
}
KeyCode::Char('1') => {
app.lock().unwrap().view = ViewMode::Eeg;
}
KeyCode::Char('2') => {
app.lock().unwrap().view = ViewMode::Ppg;
}
KeyCode::Char('v') => {
let mut s = app.lock().unwrap();
s.smooth = !s.smooth;
}
KeyCode::Char('p') => {
app.lock().unwrap().paused = true;
if let Some(h) = handle.clone() {
tokio::spawn(async move { let _ = h.pause().await; });
}
}
KeyCode::Char('r') => {
app.lock().unwrap().paused = false;
if let Some(h) = handle.clone() {
tokio::spawn(async move { let _ = h.resume().await; });
}
}
KeyCode::Char('c') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
app.lock().unwrap().clear();
}
_ => {}
}
}
if let Some(h) = handle {
let _ = h.disconnect().await;
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}