Skip to main content

twinleaf_tools/tools/
tio_monitor.rs

1// tio-monitor
2// Live sensor data display with plot and FFT capabilities
3// Build: cargo run --release -- <tio-url> [options]
4
5use std::{
6    cmp::min,
7    collections::{BTreeMap, HashMap, HashSet, VecDeque},
8    fs::File,
9    io::{self, Read},
10    str::FromStr,
11    time::{Duration, Instant},
12};
13
14use clap::Parser;
15use crossbeam::channel::{self, Sender};
16use ratatui::{
17    crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
18    layout::{Constraint, Direction, Layout, Rect},
19    prelude::Stylize,
20    style::{Color, Modifier, Style},
21    symbols,
22    text::{Line, Span},
23    widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, List, Paragraph},
24    DefaultTerminal, Frame,
25};
26use toml_edit::{DocumentMut, InlineTable, Value};
27use tui_prompts::{State, TextState};
28use twinleaf::{
29    data::{AlignedWindow, Buffer, ColumnBatch, ColumnData, DeviceFullMetadata, Sample},
30    device::{util, DeviceEvent, DeviceTree, RpcClient, RpcList, RpcRegistry, TreeEvent, TreeItem},
31    tio::{
32        self,
33        proto::{
34            identifiers::{ColumnKey, StreamKey},
35            DeviceRoute, ProxyStatus,
36        },
37    },
38};
39use crate::TioOpts;
40use welch_sde::{Build, SpectralDensity};
41
42#[derive(Parser, Debug)]
43#[command(name = "tio-monitor", version, about = "Display live sensor data")]
44struct Cli {
45    #[command(flatten)]
46    tio: TioOpts,
47    #[arg(short = 'a', long = "all")]
48    all: bool,
49    #[arg(long = "fps", default_value_t = 20)]
50    fps: u32,
51    #[arg(short = 'c', long = "colors")]
52    colors: Option<String>,
53}
54
55#[derive(Debug, Clone)]
56pub enum NavPos {
57    EmptyDevice {
58        device_idx: usize,
59        route: DeviceRoute,
60    },
61    Column {
62        device_idx: usize,
63        stream_idx: usize,
64        spec: ColumnKey,
65    },
66}
67
68impl NavPos {
69    pub fn device_idx(&self) -> usize {
70        match self {
71            NavPos::EmptyDevice { device_idx, .. } => *device_idx,
72            NavPos::Column { device_idx, .. } => *device_idx,
73        }
74    }
75
76    pub fn route(&self) -> &DeviceRoute {
77        match self {
78            NavPos::EmptyDevice { route, .. } => route,
79            NavPos::Column { spec, .. } => &spec.route,
80        }
81    }
82
83    pub fn stream_idx(&self) -> Option<usize> {
84        match self {
85            NavPos::EmptyDevice { .. } => None,
86            NavPos::Column { stream_idx, .. } => Some(*stream_idx),
87        }
88    }
89
90    pub fn column_idx(&self) -> Option<usize> {
91        match self {
92            NavPos::EmptyDevice { .. } => None,
93            NavPos::Column { spec, .. } => Some(spec.column_id),
94        }
95    }
96
97    pub fn spec(&self) -> Option<&ColumnKey> {
98        match self {
99            NavPos::EmptyDevice { .. } => None,
100            NavPos::Column { spec, .. } => Some(spec),
101        }
102    }
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct Nav {
107    pub idx: usize,
108}
109
110impl Nav {
111    /// Up/Down: linear traversal through flattened tree
112    pub fn step_linear(&mut self, items: &[NavPos], backward: bool) {
113        if items.is_empty() {
114            return;
115        }
116        let len = items.len();
117        self.idx = if backward {
118            (self.idx + len - 1) % len
119        } else {
120            (self.idx + 1) % len
121        };
122    }
123
124    /// Left/Right: cycle within current stream's columns
125    pub fn step_within_stream(&mut self, items: &[NavPos], backward: bool) {
126        if items.is_empty() {
127            return;
128        }
129        let cur = &items[self.idx];
130        let (dev, stream) = match cur {
131            NavPos::EmptyDevice { .. } => return,
132            NavPos::Column {
133                device_idx,
134                stream_idx,
135                ..
136            } => (*device_idx, *stream_idx),
137        };
138
139        let siblings: Vec<usize> = items
140            .iter()
141            .enumerate()
142            .filter_map(|(i, pos)| match pos {
143                NavPos::Column {
144                    device_idx,
145                    stream_idx,
146                    ..
147                } if *device_idx == dev && *stream_idx == stream => Some(i),
148                _ => None,
149            })
150            .collect();
151
152        if let Some(pos) = siblings.iter().position(|&i| i == self.idx) {
153            let len = siblings.len();
154            let new_pos = if backward {
155                (pos + len - 1) % len
156            } else {
157                (pos + 1) % len
158            };
159            self.idx = siblings[new_pos];
160        }
161    }
162
163    /// Tab: jump to next/prev device, find best matching position
164    pub fn step_device(&mut self, items: &[NavPos], backward: bool) {
165        if items.is_empty() {
166            return;
167        }
168
169        let cur = &items[self.idx];
170        let cur_device = cur.device_idx();
171        let cur_stream = cur.stream_idx().unwrap_or(0);
172        let cur_column = cur.column_idx().unwrap_or(0);
173
174        // Find all unique device indices
175        let mut device_indices: Vec<usize> = items.iter().map(|p| p.device_idx()).collect();
176        device_indices.sort();
177        device_indices.dedup();
178
179        if device_indices.len() <= 1 {
180            return;
181        }
182
183        // Find current device position and move to next/prev
184        let dev_pos = device_indices
185            .iter()
186            .position(|&d| d == cur_device)
187            .unwrap_or(0);
188        let len = device_indices.len();
189        let new_dev_pos = if backward {
190            (dev_pos + len - 1) % len
191        } else {
192            (dev_pos + 1) % len
193        };
194        let target_device = device_indices[new_dev_pos];
195
196        // Find best match on target device
197        self.idx = items
198            .iter()
199            .enumerate()
200            .filter(|(_, pos)| pos.device_idx() == target_device)
201            .map(|(i, pos)| {
202                let dist = match pos {
203                    NavPos::EmptyDevice { .. } => (0, 0),
204                    NavPos::Column {
205                        stream_idx, spec, ..
206                    } => {
207                        let s = (*stream_idx as isize - cur_stream as isize).abs();
208                        let c = (spec.column_id as isize - cur_column as isize).abs();
209                        (s, c)
210                    }
211                };
212                (i, dist)
213            })
214            .min_by_key(|&(_, dist)| dist)
215            .map(|(i, _)| i)
216            .unwrap_or(self.idx);
217    }
218
219    pub fn home(&mut self, items: &[NavPos]) {
220        if !items.is_empty() {
221            self.idx = 0;
222        }
223    }
224
225    pub fn end(&mut self, items: &[NavPos]) {
226        if !items.is_empty() {
227            self.idx = items.len() - 1;
228        }
229    }
230}
231
232#[derive(Debug, Clone, Default)]
233pub struct Theme {
234    pub value_bounds: HashMap<String, (std::ops::RangeInclusive<f64>, bool)>,
235}
236
237impl Theme {
238    pub fn get_value_color(&self, stream: &str, col: &str, val: f64) -> Option<Color> {
239        if val.is_nan() {
240            return Some(Color::Yellow);
241        }
242        let key = format!("{}.{}", stream, col);
243        if let Some((range, is_temp)) = self.value_bounds.get(&key) {
244            if val < *range.start() {
245                Some(if *is_temp { Color::Blue } else { Color::Red })
246            } else if val > *range.end() {
247                Some(Color::Red)
248            } else {
249                Some(Color::Green)
250            }
251        } else {
252            None
253        }
254    }
255}
256
257pub struct StyleContext {
258    pub is_selected: bool,
259    pub is_stale: bool,
260    pub in_plot_mode: bool,
261    pub base_color: Color,
262}
263
264impl Default for StyleContext {
265    fn default() -> Self {
266        Self {
267            is_selected: false,
268            is_stale: false,
269            in_plot_mode: false,
270            base_color: Color::Reset,
271        }
272    }
273}
274
275impl StyleContext {
276    pub fn new() -> Self {
277        Self::default()
278    }
279    pub fn selected(mut self, yes: bool) -> Self {
280        self.is_selected = yes;
281        self
282    }
283    pub fn stale(mut self, yes: bool) -> Self {
284        self.is_stale = yes;
285        self
286    }
287    pub fn plot_mode(mut self, yes: bool) -> Self {
288        self.in_plot_mode = yes;
289        self
290    }
291    pub fn color(mut self, c: Color) -> Self {
292        self.base_color = c;
293        self
294    }
295
296    pub fn resolve(&self) -> Style {
297        let mut s = Style::default().fg(self.base_color);
298        if self.is_stale {
299            s = s.add_modifier(Modifier::DIM);
300        }
301        if self.is_selected {
302            s = s.add_modifier(Modifier::BOLD);
303            if !self.in_plot_mode {
304                s = s.add_modifier(Modifier::RAPID_BLINK);
305            }
306        }
307        s
308    }
309}
310
311#[derive(Debug, Clone, Default)]
312pub struct DeviceStatus {
313    pub last_heartbeat: Option<Instant>,
314    pub connected: bool,
315}
316
317impl DeviceStatus {
318    pub fn on_heartbeat(&mut self) {
319        self.last_heartbeat = Some(Instant::now());
320        self.connected = true;
321    }
322
323    pub fn is_alive(&self, timeout: Duration) -> bool {
324        self.last_heartbeat
325            .map(|t| t.elapsed() < timeout)
326            .unwrap_or(false)
327    }
328}
329
330#[derive(Debug, Clone, PartialEq)]
331pub enum Mode {
332    Normal,
333    Command,
334}
335
336#[derive(Debug, Clone)]
337pub enum Action {
338    Quit,
339    SetMode(Mode),
340    AutoCompleteTab,
341    AutoCompleteBack,
342    NewCommandString,
343    SubmitCommand,
344    AcceptCompletion,
345    NavUp,
346    NavDown,
347    NavLeft,
348    NavRight,
349    NavTabNext,
350    NavTabPrev,
351    NavScroll(i16),
352    NavHome,
353    NavEnd,
354    TogglePlot,
355    ClosePlot,
356    ToggleFft,
357    ToggleFooter,
358    ToggleRoutes,
359    AdjustWindow(f64),
360    AdjustPlotWidth(i16),
361    AdjustPrecision(i8),
362    HistoryNavigate(HistDir),
363}
364
365#[derive(Debug, Clone)]
366pub enum HistDir {
367    Up,
368    Down,
369}
370
371#[derive(Debug, Clone)]
372pub struct ViewConfig {
373    pub show_plot: bool,
374    pub show_footer: bool,
375    pub show_routes: bool,
376    pub show_fft: bool,
377    pub plot_window_seconds: f64,
378    pub plot_width_percent: u16,
379    pub axis_precision: usize,
380    pub follow_selection: bool,
381    pub scroll: u16,
382    pub desc_width: usize,
383    pub units_width: usize,
384    pub theme: Theme,
385}
386
387impl Default for ViewConfig {
388    fn default() -> Self {
389        Self {
390            show_plot: false,
391            show_footer: true,
392            show_routes: false,
393            show_fft: false,
394            plot_window_seconds: 5.0,
395            plot_width_percent: 70,
396            axis_precision: 3,
397            follow_selection: true,
398            scroll: 0,
399            desc_width: 0,
400            units_width: 0,
401            theme: Theme::default(),
402        }
403    }
404}
405
406#[derive(Debug)]
407pub struct RpcReq {
408    pub route: DeviceRoute,
409    pub meta: Option<u16>,
410    pub method: String,
411    pub arg: Option<String>,
412}
413
414#[derive(Debug)]
415pub struct RpcResp {
416    pub result: Result<String, String>,
417}
418
419enum RpcWorkerReq {
420    FetchList(DeviceRoute),
421    Execute(RpcReq),
422}
423
424enum RpcWorkerResp {
425    List(RpcList),
426    RpcResult(RpcResp),
427}
428
429fn exec_rpc(client: &RpcClient, req: &RpcReq) -> Result<String, String> {
430    let meta = match req.meta {
431        Some(m) => m,
432        None => client
433            .rpc(&req.route, "rpc.info", &req.method)
434            .map_err(|_| format!("Unknown RPC: {}", req.method))?,
435    };
436
437    let spec = util::parse_rpc_spec(meta, req.method.clone());
438
439    let payload = if let Some(ref s) = req.arg {
440        util::rpc_encode_arg(s, &spec.data_kind).map_err(|e| format!("{:?}", e))?
441    } else {
442        Vec::new()
443    };
444
445    let reply_bytes = client
446        .raw_rpc(&req.route, &req.method, &payload)
447        .map_err(|e| format!("{:?}", e))?;
448
449    if reply_bytes.is_empty() {
450        return Ok("OK".to_string());
451    }
452
453    let value =
454        util::rpc_decode_reply(&reply_bytes, &spec.data_kind).map_err(|e| format!("{:?}", e))?;
455
456    Ok(util::format_rpc_value_for_cli(&value, &spec.data_kind))
457}
458
459pub struct App {
460    pub all: bool,
461    pub parent_route: DeviceRoute,
462    pub mode: Mode,
463    pub view: ViewConfig,
464
465    pub nav: Nav,
466    pub nav_items: Vec<NavPos>,
467
468    pub discovered_routes: HashSet<DeviceRoute>,
469    pub device_status: HashMap<DeviceRoute, DeviceStatus>,
470    pub last: BTreeMap<StreamKey, (Sample, Instant)>,
471    pub device_metadata: HashMap<DeviceRoute, DeviceFullMetadata>,
472    pub window_aligned: Option<AlignedWindow>,
473
474    pub footer_height: u16,
475    pub rpc_registries: HashMap<DeviceRoute, RpcRegistry>,
476    pub suggested_rpcs: VecDeque<String>,
477    pub suggested_rpcs_len: usize,
478    pub suggested_rpcs_ind: usize,
479
480    pub input_state: TextState<'static>,
481    pub current_completion: String,
482    pub cmd_history: Vec<String>,
483    pub history_ptr: usize,
484    pub present_command: String,
485    pub last_rpc_result: Option<(String, Color)>,
486    pub last_rpc_command: String,
487    pub blink_state: bool,
488    pub last_blink: Instant,
489}
490
491const RPCLIST_MAX_LEN: usize = 12;
492
493impl App {
494    pub fn new(all: bool, parent_route: &DeviceRoute) -> Self {
495        Self {
496            all,
497            parent_route: parent_route.clone(),
498            mode: Mode::Normal,
499            view: ViewConfig::default(),
500            nav: Nav::default(),
501            nav_items: Vec::new(),
502            discovered_routes: HashSet::new(),
503            device_status: HashMap::new(),
504            last: BTreeMap::new(),
505            device_metadata: HashMap::new(),
506            window_aligned: None,
507            footer_height: 0,
508            rpc_registries: HashMap::new(),
509            suggested_rpcs: VecDeque::from(vec![String::new()]),
510            suggested_rpcs_len: 1,
511            suggested_rpcs_ind: 0,
512            input_state: TextState::default(),
513            current_completion: String::new(),
514            cmd_history: Vec::new(),
515            history_ptr: 0,
516            present_command: String::new(),
517            last_rpc_result: None,
518            last_rpc_command: String::new(),
519            blink_state: true,
520            last_blink: Instant::now(),
521        }
522    }
523
524    fn update(&mut self, action: Action, rpc_tx: &Sender<RpcWorkerReq>) -> bool {
525        match action {
526            Action::Quit => return true,
527            Action::SetMode(Mode::Command) => {
528                self.input_state = TextState::default();
529                self.input_state.focus();
530                self.history_ptr = self.cmd_history.len();
531                self.update_command_list();
532                self.mode = Mode::Command;
533            }
534            Action::SetMode(Mode::Normal) => {
535                self.mode = Mode::Normal;
536                self.input_state.blur();
537            }
538            Action::AutoCompleteTab => self.tab_complete(),
539            Action::AutoCompleteBack => self.tab_back_complete(),
540            Action::NewCommandString => self.update_command_list(),
541            Action::SubmitCommand => self.submit_command(rpc_tx),
542            Action::AcceptCompletion => self.accept_completion(),
543            Action::HistoryNavigate(dir) => self.navigate_history(dir),
544            Action::NavUp => {
545                self.view.follow_selection = true;
546                self.nav.step_linear(&self.nav_items, true);
547            }
548            Action::NavDown => {
549                self.view.follow_selection = true;
550                self.nav.step_linear(&self.nav_items, false);
551            }
552            Action::NavLeft => {
553                self.view.follow_selection = true;
554                self.nav.step_within_stream(&self.nav_items, true);
555            }
556            Action::NavRight => {
557                self.view.follow_selection = true;
558                self.nav.step_within_stream(&self.nav_items, false);
559            }
560            Action::NavTabNext => {
561                self.view.follow_selection = true;
562                self.nav.step_device(&self.nav_items, false);
563            }
564            Action::NavTabPrev => {
565                self.view.follow_selection = true;
566                self.nav.step_device(&self.nav_items, true);
567            }
568            Action::NavScroll(delta) => {
569                self.view.follow_selection = false;
570                self.view.scroll = if delta < 0 {
571                    self.view.scroll.saturating_sub(delta.abs() as u16)
572                } else {
573                    self.view.scroll.saturating_add(delta as u16)
574                };
575            }
576            Action::NavHome => {
577                self.view.follow_selection = true;
578                self.nav.home(&self.nav_items);
579            }
580            Action::NavEnd => {
581                self.view.follow_selection = true;
582                self.nav.end(&self.nav_items);
583            }
584            Action::TogglePlot => {
585                if self.current_selection().is_some() {
586                    self.view.show_plot = !self.view.show_plot;
587                }
588            }
589            Action::ClosePlot => {
590                self.view.show_plot = false;
591            }
592            Action::ToggleFft => {
593                if self.view.show_plot {
594                    self.view.show_fft = !self.view.show_fft;
595                }
596            }
597            Action::ToggleFooter => self.view.show_footer = !self.view.show_footer,
598            Action::ToggleRoutes => self.view.show_routes = !self.view.show_routes,
599            Action::AdjustWindow(d) => {
600                self.view.plot_window_seconds = (self.view.plot_window_seconds + d).clamp(0.5, 10.0)
601            }
602            Action::AdjustPlotWidth(d) => {
603                self.view.plot_width_percent =
604                    (self.view.plot_width_percent as i16 + d).clamp(20, 90) as u16
605            }
606            Action::AdjustPrecision(delta) => {
607                let new_p = self.view.axis_precision as i16 + delta as i16;
608                self.view.axis_precision = new_p.clamp(0, 5) as usize;
609            }
610        }
611        false
612    }
613
614    fn complete_command(&mut self) {
615        let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone();
616        self.current_completion = match rpc.get(self.input_state.value().len()..) {
617            Some(s) => {
618                if self.input_state.value().is_empty() {
619                    rpc.clone()
620                } else if rpc.starts_with(self.input_state.value()) {
621                    s.to_string()
622                } else {
623                    String::new()
624                }
625            }
626            None => String::new(),
627        };
628        self.input_state.focus();
629        self.input_state.move_end();
630    }
631
632    fn tab_complete(&mut self) {
633        let max = std::cmp::min(RPCLIST_MAX_LEN, (self.footer_height - 5).into());
634        self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) {
635            (i, l) if i == l - 1 => 0,
636            (i, l) if l <= max => i + 1,
637            (i, _) if i < max / 2 => i + 1,
638            (i, _) => {
639                // middle of wrapped list, move list instead of index
640                let front = self.suggested_rpcs.pop_front().unwrap();
641                self.suggested_rpcs.push_back(front);
642                i
643            }
644        };
645        self.complete_command();
646    }
647
648    fn tab_back_complete(&mut self) {
649        let max = std::cmp::min(RPCLIST_MAX_LEN, (self.footer_height - 5).into());
650        self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) {
651            (0, l) if l <= max => l - 1,
652            (0, _) => {
653                // 0, move list instead of index
654                let back = self.suggested_rpcs.pop_back().unwrap();
655                self.suggested_rpcs.push_front(back);
656                0
657            }
658            (i, _) => i - 1,
659        };
660        self.complete_command();
661    }
662
663    fn update_command_list(&mut self) {
664        self.suggested_rpcs_ind = 0;
665        self.current_completion = String::new();
666        let line = self.input_state.value().to_string();
667        let query = line.split_whitespace().next().unwrap_or("");
668
669        let suggestions: Vec<String> =
670            if let Some(registry) = self.rpc_registries.get(&self.current_route()) {
671                if query.is_empty() {
672                    registry
673                        .children_of("")
674                        .into_iter()
675                        .map(|s| s + "...")
676                        .collect()
677                } else {
678                    registry.search(query)
679                }
680            } else {
681                Vec::new()
682            };
683
684        self.suggested_rpcs = VecDeque::from(suggestions);
685        self.suggested_rpcs_len = self.suggested_rpcs.len();
686        if !(1..=RPCLIST_MAX_LEN).contains(&self.suggested_rpcs_len) {
687            self.suggested_rpcs.push_back(String::new());
688            self.suggested_rpcs_len += 1;
689        }
690        self.complete_command();
691    }
692
693    fn accept_completion(&mut self) {
694        let mut complete_command: String;
695        if self.current_completion.is_empty() {
696            complete_command = self.suggested_rpcs[self.suggested_rpcs_ind].clone();
697        } else {
698            complete_command = format!(
699                "{}{}",
700                self.input_state.value().to_string(),
701                self.current_completion
702            );
703            complete_command = complete_command.replace("...", ".");
704        }
705        self.input_state = TextState::new().with_value(complete_command);
706        self.input_state.focus();
707        self.input_state.move_end();
708        self.update_command_list();
709    }
710
711    fn submit_command(&mut self, rpc_tx: &Sender<RpcWorkerReq>) {
712        let line = self.input_state.value().to_string();
713        // Accept completion if no argument provided and input doesn't match suggestion
714        if !line.contains(' ') && self.suggested_rpcs.get(self.suggested_rpcs_ind) != Some(&line) {
715            return self.accept_completion();
716        }
717        if line.trim().is_empty() {
718            return;
719        }
720        if self.cmd_history.last() != Some(&line) {
721            self.cmd_history.push(line.clone());
722        }
723        self.history_ptr = self.cmd_history.len();
724
725        let mut parts = line.split_whitespace();
726        if let Some(method) = parts.next() {
727            self.last_rpc_command = method.to_string();
728            let remainder: Vec<&str> = parts.collect();
729            let arg = if remainder.is_empty() {
730                None
731            } else {
732                Some(remainder.join(" "))
733            };
734            let route = self.current_route();
735            let meta = self
736                .rpc_registries
737                .get(&route)
738                .and_then(|r| r.find(method))
739                .map(|d| d.meta_raw);
740            let _ = rpc_tx.send(RpcWorkerReq::Execute(RpcReq {
741                route: route.clone(),
742                meta,
743                method: method.to_string(),
744                arg,
745            }));
746            self.last_rpc_result = Some((format!("Sent to {}...", route), Color::Yellow));
747            self.input_state = TextState::default();
748            self.input_state.focus();
749            self.update_command_list();
750            self.present_command = String::new();
751        }
752    }
753
754    fn update_rpclists(&mut self, list: RpcList) {
755        let registry = RpcRegistry::from(&list);
756        self.rpc_registries.insert(list.route.clone(), registry);
757    }
758
759    fn navigate_history(&mut self, dir: HistDir) {
760        if self.history_ptr == self.cmd_history.len() {
761            self.present_command = self.input_state.value().to_string();
762        };
763        self.history_ptr = match dir {
764            HistDir::Up => self.history_ptr.saturating_sub(1),
765            HistDir::Down => min(self.cmd_history.len(), self.history_ptr + 1),
766        };
767
768        self.input_state = TextState::new().with_value(
769            self.cmd_history
770                .get(self.history_ptr)
771                .unwrap_or(&self.present_command)
772                .clone(),
773        );
774        self.input_state.focus();
775        self.input_state.move_end();
776        self.update_command_list();
777    }
778
779    pub fn visible_routes(&self) -> Vec<DeviceRoute> {
780        if self.all {
781            let mut routes: Vec<_> = self.discovered_routes.iter().cloned().collect();
782            routes.sort();
783            routes
784        } else {
785            vec![self.parent_route.clone()]
786        }
787    }
788
789    pub fn rebuild_nav_items(&mut self) {
790        let routes = self.visible_routes();
791        let mut new_items = Vec::new();
792
793        for (dev_idx, route) in routes.iter().enumerate() {
794            let mut stream_ids: Vec<_> = self
795                .last
796                .keys()
797                .filter(|k| &k.route == route)
798                .map(|k| k.stream_id)
799                .collect();
800            stream_ids.sort();
801            stream_ids.dedup();
802
803            if stream_ids.is_empty() {
804                // Device with no streams is still a stop
805                new_items.push(NavPos::EmptyDevice {
806                    device_idx: dev_idx,
807                    route: route.clone(),
808                });
809            } else {
810                for (stream_idx, sid) in stream_ids.iter().enumerate() {
811                    let key = StreamKey::new(route.clone(), *sid);
812                    if let Some((sample, _)) = self.last.get(&key) {
813                        for (column_idx, _) in sample.columns.iter().enumerate() {
814                            new_items.push(NavPos::Column {
815                                device_idx: dev_idx,
816                                stream_idx,
817                                spec: ColumnKey {
818                                    route: route.clone(),
819                                    stream_id: *sid,
820                                    column_id: column_idx,
821                                },
822                            });
823                        }
824                    }
825                }
826            }
827        }
828
829        self.nav_items = new_items;
830
831        // Clamp index
832        if self.nav_items.is_empty() {
833            self.nav.idx = 0;
834        } else {
835            self.nav.idx = self.nav.idx.min(self.nav_items.len() - 1);
836        }
837    }
838
839    pub fn rpc_list_len(&self) -> u16 {
840        let length: usize = self.suggested_rpcs_len;
841        std::cmp::min(length, RPCLIST_MAX_LEN).try_into().unwrap()
842    }
843
844    pub fn current_pos(&self) -> Option<&NavPos> {
845        self.nav_items.get(self.nav.idx)
846    }
847
848    pub fn current_selection(&self) -> Option<ColumnKey> {
849        self.current_pos().and_then(|p| p.spec().cloned())
850    }
851
852    pub fn current_route(&self) -> DeviceRoute {
853        self.current_pos()
854            .map(|p| p.route().clone())
855            .unwrap_or_else(|| self.parent_route.clone())
856    }
857
858    pub fn current_device_index(&self) -> usize {
859        self.current_pos().map(|p| p.device_idx()).unwrap_or(0)
860    }
861
862    pub fn device_count(&self) -> usize {
863        self.visible_routes().len()
864    }
865
866    fn handle_event(&mut self, event: TreeEvent, rpc_tx: &Sender<RpcWorkerReq>) {
867        match event {
868            TreeEvent::RouteDiscovered(route) => {
869                self.discovered_routes.insert(route.clone());
870                let _ = rpc_tx.send(RpcWorkerReq::FetchList(route.clone()));
871                self.device_status.entry(route).or_default();
872            }
873            TreeEvent::Device {
874                route,
875                event: DeviceEvent::NewHash(hash),
876            } => {
877                match (self.rpc_registries.get(&route), hash) {
878                    (Some(reg), Some(hash)) if reg.hash == Some(hash) => {}
879                    _ => {
880                        self.rpc_registries.remove(&route);
881                        let _ = rpc_tx.send(RpcWorkerReq::FetchList(route));
882                    }
883                };
884            }
885            TreeEvent::Device {
886                route,
887                event: DeviceEvent::Heartbeat { .. },
888            } => {
889                self.device_status.entry(route).or_default().on_heartbeat();
890            }
891            TreeEvent::Device {
892                route,
893                event: DeviceEvent::Status(status),
894            } => {
895                let dev_status = self.device_status.entry(route.clone()).or_default();
896                match status {
897                    ProxyStatus::SensorDisconnected => dev_status.connected = false,
898                    ProxyStatus::SensorReconnected => dev_status.connected = true,
899                    _ => {}
900                }
901            }
902            TreeEvent::Device {
903                route,
904                event: DeviceEvent::MetadataReady(metadata),
905            } => {
906                self.device_metadata.insert(route, metadata);
907            }
908            TreeEvent::Device {
909                event: DeviceEvent::RpcInvalidated(_),
910                ..
911            } => {}
912        }
913    }
914
915    pub fn handle_sample(&mut self, sample: Sample, route: DeviceRoute, buffer: &mut Buffer) {
916        let stream_key = StreamKey::new(route.clone(), sample.stream.stream_id);
917        buffer.process_sample(sample.clone(), stream_key.clone());
918        self.last.insert(stream_key, (sample, Instant::now()));
919    }
920
921    pub fn update_plot_window(&mut self, buffer: &Buffer) {
922        if !self.view.show_plot {
923            self.window_aligned = None;
924            return;
925        }
926
927        self.window_aligned = self.current_selection().and_then(|col| {
928            let stream_key = col.stream_key();
929            let run = buffer.get_run(&stream_key)?;
930            let n_samples = (self.view.plot_window_seconds * run.effective_rate)
931                .ceil()
932                .max(10.0) as usize;
933            buffer.read_aligned_window(&[col], n_samples).ok()
934        });
935    }
936
937    pub fn get_plot_data(&self) -> Option<(Vec<(f64, f64)>, f64, f64)> {
938        let spec = self.current_selection()?;
939        let win = self.window_aligned.as_ref()?;
940        let batch = win.columns.get(&spec)?;
941        if win.timestamps.is_empty() {
942            return None;
943        }
944        let data: Vec<(f64, f64)> = match batch {
945            ColumnBatch::F64(v) => win
946                .timestamps
947                .iter()
948                .copied()
949                .zip(v.iter().copied())
950                .collect(),
951            ColumnBatch::I64(v) => win
952                .timestamps
953                .iter()
954                .copied()
955                .zip(v.iter().map(|&x| x as f64))
956                .collect(),
957            ColumnBatch::U64(v) => win
958                .timestamps
959                .iter()
960                .copied()
961                .zip(v.iter().map(|&x| x as f64))
962                .collect(),
963        };
964        if data.is_empty() {
965            return None;
966        }
967        let (cur_t, cur_v) = *data.last().unwrap();
968        Some((data, cur_v, cur_t))
969    }
970
971    pub fn get_spectral_density_data(&self) -> Option<(Vec<(f64, f64)>, f64)> {
972        let spec = self.current_selection()?;
973        let win = self.window_aligned.as_ref()?;
974        let stream_key = spec.stream_key();
975        let md = win.segment_metadata.get(&stream_key)?;
976        let sampling_hz = (md.sampling_rate / md.decimation) as f64;
977        let batch = win.columns.get(&spec)?;
978
979        let signal: Vec<f64> = match batch {
980            ColumnBatch::F64(v) => v.clone(),
981            ColumnBatch::I64(v) => v.iter().map(|&x| x as f64).collect(),
982            ColumnBatch::U64(v) => v.iter().map(|&x| x as f64).collect(),
983        };
984
985        if signal.len() < 128 {
986            return None;
987        }
988
989        let mean_val = signal.iter().sum::<f64>() / signal.len() as f64;
990        let detrended: Vec<f64> = signal.iter().map(|x| x - mean_val).collect();
991
992        let welch: SpectralDensity<f64> = SpectralDensity::builder(&detrended, sampling_hz).build();
993        let sd = welch.periodogram();
994        let raw: Vec<f64> = sd.iter().copied().collect();
995
996        let pts: Vec<(f64, f64)> = sd
997            .frequency()
998            .into_iter()
999            .zip(raw.into_iter())
1000            .filter(|(f, d)| *f > 0.0 && d.is_finite() && *d > 0.0)
1001            .collect();
1002
1003        if pts.is_empty() {
1004            return None;
1005        }
1006
1007        let mut densities: Vec<f64> = pts.iter().map(|(_, d)| *d).collect();
1008        densities.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1009        let median_asd = if densities.len() % 2 == 0 {
1010            (densities[densities.len() / 2 - 1] + densities[densities.len() / 2]) / 2.0
1011        } else {
1012            densities[densities.len() / 2]
1013        };
1014
1015        Some((pts, median_asd))
1016    }
1017
1018    pub fn get_focused_channel_info(&self) -> Option<(String, String)> {
1019        let spec = self.current_selection()?;
1020        let win = self.window_aligned.as_ref()?;
1021        let meta = win.column_metadata.get(&spec)?;
1022        Some((meta.description.clone(), meta.units.clone()))
1023    }
1024
1025    pub fn tick_blink(&mut self) {
1026        if self.last_blink.elapsed() >= Duration::from_millis(500) {
1027            self.blink_state = !self.blink_state;
1028            self.last_blink = Instant::now();
1029        }
1030    }
1031}
1032
1033fn get_action(ev: Event, app: &mut App) -> Option<Action> {
1034    if let Event::Key(k) = ev {
1035        if k.kind != KeyEventKind::Press {
1036            return None;
1037        }
1038        match app.mode {
1039            Mode::Command => match k.code {
1040                KeyCode::Esc => Some(Action::SetMode(Mode::Normal)),
1041                KeyCode::Char('c') if k.modifiers == KeyModifiers::CONTROL => {
1042                    Some(Action::SetMode(Mode::Normal))
1043                }
1044                KeyCode::Tab => Some(Action::AutoCompleteTab),
1045                KeyCode::BackTab => Some(Action::AutoCompleteBack),
1046                KeyCode::Up => Some(Action::HistoryNavigate(HistDir::Up)),
1047                KeyCode::Down => Some(Action::HistoryNavigate(HistDir::Down)),
1048                KeyCode::Right if !app.current_completion.is_empty() => {
1049                    Some(Action::AcceptCompletion)
1050                }
1051                KeyCode::Right => {
1052                    app.input_state.handle_key_event(k);
1053                    None
1054                }
1055                KeyCode::Left => {
1056                    app.current_completion = String::new();
1057                    app.input_state.handle_key_event(k);
1058                    None
1059                }
1060                KeyCode::Enter => Some(Action::SubmitCommand),
1061                KeyCode::Char('a') if k.modifiers == KeyModifiers::CONTROL => {
1062                    app.current_completion = String::new();
1063                    app.input_state.handle_key_event(k);
1064                    None
1065                }
1066                _ => {
1067                    app.input_state.handle_key_event(k);
1068                    Some(Action::NewCommandString)
1069                }
1070            },
1071            Mode::Normal => match k.code {
1072                KeyCode::Char(':') => Some(Action::SetMode(Mode::Command)),
1073                KeyCode::Char('q') => Some(Action::Quit),
1074                KeyCode::Char('c') if k.modifiers == KeyModifiers::CONTROL => Some(Action::Quit),
1075                KeyCode::Esc => Some(Action::ClosePlot),
1076                KeyCode::Up => Some(Action::NavUp),
1077                KeyCode::Down => Some(Action::NavDown),
1078                KeyCode::Left => Some(Action::NavLeft),
1079                KeyCode::Right => Some(Action::NavRight),
1080                KeyCode::BackTab => Some(Action::NavTabPrev),
1081                KeyCode::Tab => Some(Action::NavTabNext),
1082                KeyCode::PageUp => Some(Action::NavScroll(-10)),
1083                KeyCode::PageDown => Some(Action::NavScroll(10)),
1084                KeyCode::Home => Some(Action::NavHome),
1085                KeyCode::End => Some(Action::NavEnd),
1086                KeyCode::Enter => Some(Action::TogglePlot),
1087                KeyCode::Char('f') => Some(Action::ToggleFft),
1088                KeyCode::Char('h') => Some(Action::ToggleFooter),
1089                KeyCode::Char('r') => Some(Action::ToggleRoutes),
1090                KeyCode::Char('+') | KeyCode::Char('=') => Some(Action::AdjustWindow(0.5)),
1091                KeyCode::Char('-') | KeyCode::Char('_') => Some(Action::AdjustWindow(-0.5)),
1092                KeyCode::Char('[') => Some(Action::AdjustPlotWidth(5)),
1093                KeyCode::Char(']') => Some(Action::AdjustPlotWidth(-5)),
1094                KeyCode::Char(',') | KeyCode::Char('<') => Some(Action::AdjustPrecision(-1)),
1095                KeyCode::Char('.') | KeyCode::Char('>') => Some(Action::AdjustPrecision(1)),
1096                _ => None,
1097            },
1098        }
1099    } else {
1100        None
1101    }
1102}
1103
1104fn draw_ui(terminal: &mut DefaultTerminal, app: &mut App) -> Result<(), io::Error> {
1105    terminal.draw(|f| {
1106        let size = f.area();
1107        let height = size.height;
1108
1109        let (main_area, footer_area) = {
1110            let (main_constraint, footer_constraint) = if app.mode == Mode::Command {
1111                if height >= 18 {
1112                    (
1113                        Constraint::Min(10),
1114                        Constraint::Length(5 + app.rpc_list_len()),
1115                    )
1116                } else if height >= 12 {
1117                    (Constraint::Min(2), Constraint::Length(8))
1118                } else if height >= 5 {
1119                    (Constraint::Min(2), Constraint::Length(3))
1120                } else {
1121                    (Constraint::Min(0), Constraint::Length(2))
1122                }
1123            } else if app.view.show_footer {
1124                (Constraint::Min(10), Constraint::Length(6))
1125            } else {
1126                (Constraint::Min(10), Constraint::Length(2))
1127            };
1128            let chunks = Layout::default()
1129                .direction(Direction::Vertical)
1130                .constraints([main_constraint, footer_constraint])
1131                .split(size);
1132            (chunks[0], Some(chunks[1]))
1133        };
1134
1135        let (left, right) = if app.mode == Mode::Command && height < 3 {
1136            (None, None)
1137        } else if app.view.show_plot {
1138            let chunks = Layout::default()
1139                .direction(Direction::Horizontal)
1140                .constraints([
1141                    Constraint::Percentage(100 - app.view.plot_width_percent),
1142                    Constraint::Percentage(app.view.plot_width_percent),
1143                ])
1144                .split(main_area);
1145            (Some(chunks[0]), Some(chunks[1]))
1146        } else {
1147            (Some(main_area), None)
1148        };
1149
1150        if let Some(l) = left {
1151            render_monitor_panel(f, app, l, Instant::now());
1152        }
1153        if let Some(r) = right {
1154            render_graphics_panel(f, app, r);
1155        }
1156        if let Some(foot) = footer_area {
1157            render_footer(f, app, foot);
1158        }
1159    })?;
1160    Ok(())
1161}
1162
1163fn render_monitor_panel(f: &mut Frame, app: &mut App, area: Rect, now: Instant) {
1164    let inner = Rect {
1165        x: area.x,
1166        y: area.y,
1167        width: area.width.saturating_sub(1),
1168        height: area.height,
1169    };
1170    let (lines, col_map) = build_left_lines(app, now);
1171    let total = lines.len();
1172    let view_h = inner.height as usize;
1173
1174    if app.view.follow_selection {
1175        if let Some(&line_idx) = col_map.get(&app.nav.idx) {
1176            if view_h > 0 && total > view_h {
1177                let cur = app.view.scroll as usize;
1178                if line_idx < cur || line_idx >= cur + view_h {
1179                    app.view.scroll = line_idx
1180                        .saturating_sub(view_h / 2)
1181                        .min(total.saturating_sub(view_h))
1182                        as u16;
1183                }
1184            } else {
1185                app.view.scroll = 0;
1186            }
1187        }
1188    }
1189    app.view.scroll = (app.view.scroll as usize).min(total.saturating_sub(view_h)) as u16;
1190    f.render_widget(Paragraph::new(lines).scroll((app.view.scroll, 0)), inner);
1191
1192    if total > view_h {
1193        let sb_area = Rect {
1194            x: area.x + area.width - 1,
1195            y: area.y,
1196            width: 1,
1197            height: area.height,
1198        };
1199        let track_len = view_h;
1200        let thumb_len = (track_len * track_len / total).max(1);
1201        let max_thumb_pos = track_len - thumb_len;
1202        let scroll_max = total - track_len;
1203        let thumb_pos = (app.view.scroll as usize * max_thumb_pos) / scroll_max;
1204
1205        for i in 0..track_len {
1206            let ch = if i >= thumb_pos && i < thumb_pos + thumb_len {
1207                "█"
1208            } else {
1209                "│"
1210            };
1211            f.render_widget(
1212                Paragraph::new(ch).style(Style::default().fg(Color::DarkGray)),
1213                Rect {
1214                    x: sb_area.x,
1215                    y: sb_area.y + i as u16,
1216                    width: 1,
1217                    height: 1,
1218                },
1219            );
1220        }
1221    }
1222}
1223
1224fn build_left_lines(app: &mut App, now: Instant) -> (Vec<Line<'static>>, HashMap<usize, usize>) {
1225    let mut lines = Vec::new();
1226    let mut map = HashMap::new();
1227
1228    let routes = app.visible_routes();
1229
1230    if routes.is_empty() {
1231        lines.push(Line::from("Waiting for data..."));
1232        return (lines, map);
1233    }
1234
1235    let mut global_idx = 0;
1236    app.view.desc_width = app
1237        .last
1238        .values()
1239        .flat_map(|(s, _)| s.columns.iter())
1240        .map(|c| c.desc.description.len())
1241        .max()
1242        .unwrap_or(0);
1243    app.view.units_width = app
1244        .last
1245        .values()
1246        .flat_map(|(s, _)| s.columns.iter())
1247        .map(|c| c.desc.units.len())
1248        .max()
1249        .unwrap_or(0);
1250
1251    for (dev_idx, route) in routes.iter().enumerate() {
1252        let dev = app.device_metadata.get(route).map(|m| m.device.as_ref());
1253
1254        let status = app.device_status.get(route);
1255        let is_alive = status
1256            .map(|s| s.is_alive(Duration::from_millis(300)))
1257            .unwrap_or(false);
1258
1259        let head_style = if dev_idx == app.current_device_index() {
1260            Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1261        } else {
1262            Style::default().add_modifier(Modifier::BOLD)
1263        };
1264
1265        let header_text = if let Some(d) = dev {
1266            if d.serial_number.is_empty() {
1267                d.name.clone()
1268            } else {
1269                format!("{}  Serial: {}", d.name, d.serial_number)
1270            }
1271        } else {
1272            format!("<{}>", route)
1273        };
1274
1275        let status_indicator = if is_alive { "●" } else { "○" };
1276        let status_color = if is_alive {
1277            Color::Green
1278        } else {
1279            Color::DarkGray
1280        };
1281
1282        let mut header_spans = vec![
1283            Span::styled(
1284                format!("{} ", status_indicator),
1285                Style::default().fg(status_color),
1286            ),
1287            Span::styled(header_text, head_style),
1288        ];
1289        if app.view.show_routes {
1290            header_spans.push(Span::raw(format!(" [{}]", route)));
1291        }
1292
1293        lines.push(Line::from(header_spans));
1294
1295        let mut stream_ids: Vec<_> = app
1296            .last
1297            .keys()
1298            .filter(|k| &k.route == route)
1299            .map(|k| k.stream_id)
1300            .collect();
1301        stream_ids.sort();
1302
1303        if stream_ids.is_empty() {
1304            map.insert(global_idx, lines.len());
1305            global_idx += 1;
1306
1307            lines.push(Line::from(Span::styled(
1308                "  (no streams yet)",
1309                Style::default().fg(Color::DarkGray),
1310            )));
1311        }
1312
1313        for sid in stream_ids {
1314            let key = StreamKey::new(route.clone(), sid);
1315            if let Some((sample, seen)) = app.last.get(&key) {
1316                let is_stale = now.saturating_duration_since(*seen) > Duration::from_millis(1200);
1317                for col in &sample.columns {
1318                    let nav_idx = global_idx;
1319                    global_idx += 1;
1320                    map.insert(nav_idx, lines.len());
1321
1322                    let is_sel = app.nav.idx == nav_idx;
1323                    let ctx = StyleContext::new()
1324                        .stale(is_stale)
1325                        .selected(is_sel)
1326                        .plot_mode(app.view.show_plot);
1327
1328                    let label_style = ctx.resolve();
1329                    let (val_str, val_f64) = fmt_value(&col.value);
1330                    let val_col = app
1331                        .view
1332                        .theme
1333                        .get_value_color(&sample.stream.name, &col.desc.name, val_f64)
1334                        .unwrap_or(Color::Reset);
1335                    let val_style = ctx.color(val_col).resolve();
1336
1337                    let mut desc = col.desc.description.clone();
1338                    if desc.len() < app.view.desc_width {
1339                        desc.push_str(&" ".repeat(app.view.desc_width - desc.len()));
1340                    }
1341
1342                    let units = col.desc.units.clone();
1343                    let padded_units = if app.view.units_width > 0 && !units.is_empty() {
1344                        format!("{:>width$}", units, width = app.view.units_width)
1345                    } else if app.view.units_width > 0 {
1346                        " ".repeat(app.view.units_width)
1347                    } else {
1348                        String::new()
1349                    };
1350
1351                    lines.push(Line::from(vec![
1352                        Span::styled(desc, label_style),
1353                        Span::raw("  "),
1354                        Span::styled(val_str, val_style),
1355                        Span::raw(" "),
1356                        Span::styled(padded_units, val_style),
1357                    ]));
1358                }
1359            }
1360        }
1361        lines.push(Line::from(""));
1362    }
1363    (lines, map)
1364}
1365
1366fn render_footer(f: &mut Frame, app: &mut App, area: Rect) {
1367    app.footer_height = area.height; // how many lines the footer has to work with
1368    if app.mode == Mode::Command {
1369        let chunks = Layout::default()
1370            .direction(Direction::Vertical)
1371            .constraints([
1372                Constraint::Max(app.rpc_list_len() + 2),
1373                Constraint::Length(std::cmp::min(1, app.footer_height - 1)),
1374                Constraint::Length(if app.footer_height > 2 { 2 } else { 1 }),
1375            ])
1376            .split(area);
1377
1378        if app.footer_height > 3 {
1379            let rpcs: Vec<Span> = if app.rpc_registries.get(&app.current_route()).is_some() {
1380                app.suggested_rpcs
1381                    .iter()
1382                    .map(|v| Span::raw(v.clone()))
1383                    .enumerate()
1384                    .map(|(i, v)| {
1385                        if i == app.suggested_rpcs_ind {
1386                            v.bold()
1387                        } else {
1388                            v.dim()
1389                        }
1390                    })
1391                    .collect()
1392            } else {
1393                vec![Span::from("Generating RPC list...")]
1394            };
1395
1396            let rpc_block = Block::default()
1397                .borders(Borders::ALL)
1398                .title(Line::from(" RPCs ").left_aligned())
1399                .title(Line::from(" ↑ Shift+Tab | Tab ↓ ").right_aligned());
1400            f.render_widget(List::new(rpcs).block(rpc_block), chunks[0]);
1401        }
1402
1403        if app.footer_height > 1 {
1404            if let Some((msg, color)) = &app.last_rpc_result {
1405                f.render_widget(
1406                    Paragraph::new(msg.as_str())
1407                        .style(Style::default().fg(*color).add_modifier(Modifier::BOLD)),
1408                    chunks[1],
1409                );
1410            }
1411        }
1412
1413        let target_route = app.current_route();
1414        let user_input = app.input_state.value();
1415        let cursor_idx = app.input_state.position().min(user_input.len());
1416
1417        let mut spans = vec![
1418            Span::styled(
1419                format!("[{}] ", target_route),
1420                Style::default().fg(Color::Blue),
1421            ),
1422            Span::raw(&user_input[0..cursor_idx]),
1423        ];
1424
1425        if cursor_idx < user_input.len() {
1426            spans.push(Span::styled(
1427                &user_input[cursor_idx..cursor_idx + 1],
1428                if app.blink_state {
1429                    Style::default().bg(Color::White).fg(Color::Black)
1430                } else {
1431                    Style::default()
1432                },
1433            ));
1434            spans.push(Span::raw(&user_input[cursor_idx + 1..]));
1435        } else if app.blink_state {
1436            spans.push(Span::styled(" ", Style::default().bg(Color::White)));
1437            if !app.current_completion.is_empty() {
1438                spans.push(Span::styled(
1439                    &app.current_completion[1..],
1440                    Style::default().fg(Color::Gray),
1441                ));
1442            }
1443        } else {
1444            spans.push(Span::styled(
1445                &app.current_completion,
1446                Style::default().fg(Color::Gray),
1447            ));
1448        }
1449
1450        let block = if app.footer_height < 3 {
1451            Block::default()
1452        } else {
1453            Block::default()
1454                .borders(Borders::TOP)
1455                .title(Line::from(" Command Mode ").left_aligned())
1456                .title(Line::from(" <Esc/Ctrl+C> ").right_aligned())
1457        };
1458
1459        f.render_widget(Paragraph::new(Line::from(spans)).block(block), chunks[2]);
1460        return;
1461    }
1462
1463    if !app.view.show_footer {
1464        let minimal = Line::from(vec![
1465            Span::raw("  "),
1466            key_span("h"),
1467            Span::raw(" Toggle Footer"),
1468        ]);
1469        f.render_widget(
1470            Paragraph::new(vec![minimal]).block(
1471                Block::default()
1472                    .borders(Borders::TOP)
1473                    .border_style(Style::default().fg(Color::DarkGray)),
1474            ),
1475            area,
1476        );
1477        return;
1478    }
1479
1480    let mut navigation_spans = vec![
1481        Span::styled(
1482            "  Navigation  ",
1483            Style::default()
1484                .fg(Color::Cyan)
1485                .add_modifier(Modifier::BOLD),
1486        ),
1487        key_span("↑"),
1488        key_sep(),
1489        key_span("↓"),
1490        Span::raw(" All  "),
1491        key_span("←"),
1492        key_sep(),
1493        key_span("→"),
1494        Span::raw(" Columns"),
1495    ];
1496
1497    if app.device_count() > 1 {
1498        navigation_spans.push(Span::raw("  "));
1499        navigation_spans.push(key_span("Tab"));
1500        navigation_spans.push(key_sep());
1501        navigation_spans.push(key_span("Shift+Tab"));
1502        navigation_spans.push(Span::raw(" Devices"));
1503    }
1504    let navigation_line = Line::from(navigation_spans);
1505
1506    let toggle_line = Line::from(vec![
1507        Span::styled(
1508            "  Toggle      ",
1509            Style::default()
1510                .fg(Color::Green)
1511                .add_modifier(Modifier::BOLD),
1512        ),
1513        key_span("Enter"),
1514        Span::raw(" Plot  "),
1515        key_span("f"),
1516        Span::raw(" FFT  "),
1517        key_span("h"),
1518        Span::raw(" Footer  "),
1519        key_span("r"),
1520        Span::raw(" Routes "),
1521        key_span(":"),
1522        Span::raw(" Cmd"),
1523    ]);
1524
1525    let window_line = Line::from(vec![
1526        Span::styled(
1527            "  Plot        ",
1528            Style::default()
1529                .fg(Color::Yellow)
1530                .add_modifier(Modifier::BOLD),
1531        ),
1532        key_span("+"),
1533        key_sep(),
1534        key_span("-"),
1535        Span::raw(" Window (0.5s)  "),
1536        key_span("["),
1537        key_sep(),
1538        key_span("]"),
1539        Span::raw(" Plot Width  "),
1540        key_span("<"),
1541        key_sep(),
1542        key_span(">"),
1543        Span::raw(" Plot Precision"),
1544    ]);
1545
1546    let scroll_line = Line::from(vec![
1547        Span::styled(
1548            "  Scroll      ",
1549            Style::default()
1550                .fg(Color::Magenta)
1551                .add_modifier(Modifier::BOLD),
1552        ),
1553        key_span("Home"),
1554        key_sep(),
1555        key_span("End"),
1556        key_sep(),
1557        key_span("PgUp"),
1558        key_sep(),
1559        key_span("PgDn"),
1560    ]);
1561
1562    let quit_line = Line::from(vec![
1563        Span::styled(
1564            "  Quit        ",
1565            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1566        ),
1567        key_span("q"),
1568        Span::raw(" / "),
1569        key_span("Ctrl+C"),
1570        Span::raw(" Quit"),
1571    ]);
1572
1573    let lines = vec![
1574        navigation_line,
1575        toggle_line,
1576        window_line,
1577        scroll_line,
1578        quit_line,
1579    ];
1580
1581    let block = Block::default()
1582        .borders(Borders::TOP)
1583        .border_style(Style::default().fg(Color::DarkGray))
1584        .title(Span::styled(
1585            " Controls ",
1586            Style::default().add_modifier(Modifier::BOLD),
1587        ));
1588
1589    f.render_widget(Paragraph::new(lines).block(block), area);
1590}
1591
1592fn key_span(text: &str) -> Span<'static> {
1593    Span::styled(
1594        format!(" {} ", text),
1595        Style::default()
1596            .fg(Color::White)
1597            .bg(Color::DarkGray)
1598            .add_modifier(Modifier::BOLD),
1599    )
1600}
1601
1602fn key_sep() -> Span<'static> {
1603    Span::raw(" ")
1604}
1605
1606fn render_graphics_panel(f: &mut Frame, app: &App, area: Rect) {
1607    if let (Some(pos), Some((desc, units))) = (app.current_pos(), app.get_focused_channel_info()) {
1608        let route = pos.route();
1609        if app.view.show_fft {
1610            if let Some((sd_data, median_asd)) = app.get_spectral_density_data() {
1611                let title = format!(
1612                    "{} — {} (linear detrend {:.1}s) | Median ASD: {:.3e} {}/√Hz",
1613                    route, desc, app.view.plot_window_seconds, median_asd, units
1614                );
1615                let block = Block::default().title(title).borders(Borders::ALL);
1616
1617                if !sd_data.is_empty() {
1618                    let log_data: Vec<(f64, f64)> = sd_data
1619                        .iter()
1620                        .map(|(freq, val)| (freq.log10(), val.log10()))
1621                        .collect();
1622
1623                    let min_f = log_data.first().map(|(f, _)| *f).unwrap_or(0.0);
1624                    let max_f = log_data.last().map(|(f, _)| *f).unwrap_or(1.0);
1625                    let ds: Vec<f64> = log_data.iter().map(|(_, d)| *d).collect();
1626                    let min_d = ds.iter().fold(f64::INFINITY, |a, &b| a.min(b));
1627                    let max_d = ds.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
1628
1629                    let y_pad = if (max_d - min_d) > 0.1 {
1630                        (max_d - min_d) * 0.1
1631                    } else {
1632                        0.5
1633                    };
1634
1635                    let dataset = Dataset::default()
1636                        .name(desc.as_str())
1637                        .marker(symbols::Marker::Braille)
1638                        .style(Style::default().fg(Color::Cyan))
1639                        .graph_type(GraphType::Line)
1640                        .data(&log_data);
1641
1642                    let chart = Chart::new(vec![dataset])
1643                        .block(block)
1644                        .x_axis(
1645                            Axis::default()
1646                                .title("Freq [Hz] (log)")
1647                                .bounds([min_f, max_f])
1648                                .labels(generate_log_labels(
1649                                    min_f,
1650                                    max_f,
1651                                    5,
1652                                    app.view.axis_precision,
1653                                )),
1654                        )
1655                        .y_axis(
1656                            Axis::default()
1657                                .title(format!("Val [{}/√Hz]", units))
1658                                .bounds([min_d - y_pad, max_d + y_pad])
1659                                .labels(generate_log_labels(
1660                                    min_d - y_pad,
1661                                    max_d + y_pad,
1662                                    5,
1663                                    app.view.axis_precision,
1664                                )),
1665                        );
1666                    f.render_widget(chart, area);
1667                } else {
1668                    f.render_widget(Paragraph::new("No valid FFT data").block(block), area);
1669                }
1670            } else {
1671                let block = Block::default()
1672                    .title("Buffering FFT...")
1673                    .borders(Borders::ALL);
1674                f.render_widget(Paragraph::new("Need >128 samples").block(block), area);
1675            }
1676        } else {
1677            let title = format!(
1678                "{} — {} ({:.1}s)",
1679                route, desc, app.view.plot_window_seconds
1680            );
1681            let block = Block::default().title(title).borders(Borders::ALL);
1682
1683            if let Some((data, _, _)) = app.get_plot_data() {
1684                let min_t = data.first().map(|(t, _)| *t).unwrap_or(0.0);
1685                let max_t = data.last().map(|(t, _)| *t).unwrap_or(1.0);
1686                let vs: Vec<f64> = data.iter().map(|(_, v)| *v).collect();
1687                let min_v = vs.iter().fold(f64::INFINITY, |a, &b| a.min(b));
1688                let max_v = vs.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
1689
1690                let pad = if (max_v - min_v).abs() > 1e-10 {
1691                    (max_v - min_v) * 0.4
1692                } else {
1693                    1.0
1694                };
1695
1696                let dataset = Dataset::default()
1697                    .name(desc.as_str())
1698                    .marker(symbols::Marker::Braille)
1699                    .style(Style::default().fg(Color::Green))
1700                    .graph_type(GraphType::Line)
1701                    .data(&data);
1702
1703                let chart = Chart::new(vec![dataset])
1704                    .block(block)
1705                    .x_axis(
1706                        Axis::default()
1707                            .title("Time [s]")
1708                            .bounds([min_t, max_t])
1709                            .labels(generate_linear_labels(
1710                                min_t,
1711                                max_t,
1712                                3,
1713                                app.view.axis_precision,
1714                            )),
1715                    )
1716                    .y_axis(
1717                        Axis::default()
1718                            .title(format!("Value [{}]", units))
1719                            .bounds([min_v - pad, max_v + pad])
1720                            .labels(generate_linear_labels(
1721                                min_v - pad,
1722                                max_v + pad,
1723                                5,
1724                                app.view.axis_precision,
1725                            )),
1726                    );
1727                f.render_widget(chart, area);
1728            } else {
1729                f.render_widget(Paragraph::new("Buffering...").block(block), area);
1730            }
1731        }
1732    } else {
1733        f.render_widget(
1734            Block::default()
1735                .title("Channel Detail")
1736                .borders(Borders::ALL),
1737            area,
1738        );
1739    }
1740}
1741
1742fn generate_linear_labels(
1743    min: f64,
1744    max: f64,
1745    count: usize,
1746    precision: usize,
1747) -> Vec<Span<'static>> {
1748    if count < 2 {
1749        return vec![];
1750    }
1751    let step = (max - min) / ((count - 1) as f64);
1752    (0..count)
1753        .map(|i| {
1754            let v = min + (i as f64 * step);
1755            Span::from(format!("{:>10.p$}", v, p = precision))
1756        })
1757        .collect()
1758}
1759
1760fn generate_log_labels(
1761    min_log: f64,
1762    max_log: f64,
1763    count: usize,
1764    precision: usize,
1765) -> Vec<Span<'static>> {
1766    if count < 2 {
1767        return vec![];
1768    }
1769    let step = (max_log - min_log) / ((count - 1) as f64);
1770    let max_val = 10f64.powf(max_log.max(min_log)).abs();
1771    let use_scientific = max_val < 0.01 || max_val >= 1000.0;
1772
1773    (0..count)
1774        .map(|i| {
1775            let log_val = min_log + (i as f64 * step);
1776            let real_val = 10f64.powf(log_val);
1777
1778            let s = if use_scientific {
1779                format!("{:.p$e}", real_val, p = precision)
1780            } else {
1781                format!("{:.p$}", real_val, p = precision)
1782            };
1783            Span::from(format!("{:>10}", s))
1784        })
1785        .collect()
1786}
1787
1788fn fmt_value(v: &ColumnData) -> (String, f64) {
1789    match v {
1790        ColumnData::Float(x) => (format!("{:15.4}", x), *x as f64),
1791        ColumnData::Int(x) => (format!("{:15}", x), *x as f64),
1792        ColumnData::UInt(x) => (format!("{:15}", x), *x as f64),
1793        _ => ("           type?".to_string(), f64::NAN),
1794    }
1795}
1796
1797fn load_theme(path: &str) -> io::Result<Theme> {
1798    let mut s = String::new();
1799    File::open(path)?.read_to_string(&mut s)?;
1800    let doc =
1801        DocumentMut::from_str(&s).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1802    let mut bounds = HashMap::new();
1803    for (k, v) in doc.get_values() {
1804        let col = k.iter().map(|k| k.get()).collect::<Vec<_>>().join(".");
1805        if let Value::InlineTable(it) = v {
1806            let (t, min) = if let Some(v) = get_num(it, "cold") {
1807                (true, v)
1808            } else {
1809                (false, get_num(it, "min").unwrap_or(f64::NEG_INFINITY))
1810            };
1811            let max = if let Some(v) = get_num(it, "hot") {
1812                v
1813            } else {
1814                get_num(it, "max").unwrap_or(f64::INFINITY)
1815            };
1816            bounds.insert(col, (min..=max, t));
1817        }
1818    }
1819    Ok(Theme {
1820        value_bounds: bounds,
1821    })
1822}
1823
1824fn get_num(it: &InlineTable, k: &str) -> Option<f64> {
1825    it.get(k)
1826        .and_then(|v| v.as_float().or(v.as_integer().map(|i| i as f64)))
1827}
1828pub fn run_monitor(tio: TioOpts, all: bool, fps: u32, colors: Option<String>) -> Result<(), ()> {
1829    let proxy = tio::proxy::Interface::new(&tio.root);
1830    let parent_route: DeviceRoute = tio.parse_route();
1831
1832    // Data thread
1833    let (data_tx, data_rx) = channel::unbounded::<TreeItem>();
1834    let tree_for_data =
1835        DeviceTree::open(&proxy, parent_route.clone()).expect("Failed to open device tree");
1836    std::thread::spawn(move || {
1837        let mut tree = tree_for_data;
1838        loop {
1839            match tree.next_item() {
1840                Ok(item) => {
1841                    if data_tx.send(item).is_err() {
1842                        return;
1843                    }
1844                }
1845                Err(_) => return,
1846            }
1847        }
1848    });
1849
1850    // RPC worker thread
1851    let rpc_client =
1852        RpcClient::open(&proxy, parent_route.clone()).expect("Failed to open RPC client");
1853    let (rpc_tx, rpc_rx) = channel::unbounded::<RpcWorkerReq>();
1854    let (rpc_resp_tx, rpc_resp_rx) = channel::unbounded::<RpcWorkerResp>();
1855
1856    std::thread::spawn(move || {
1857        while let Ok(req) = rpc_rx.recv() {
1858            let resp = match req {
1859                RpcWorkerReq::FetchList(route) => match rpc_client.rpc_list(&route) {
1860                    Ok(list) => Some(RpcWorkerResp::List(list)),
1861                    Err(_) => None,
1862                },
1863                RpcWorkerReq::Execute(rpc_req) => {
1864                    let result = exec_rpc(&rpc_client, &rpc_req);
1865                    Some(RpcWorkerResp::RpcResult(RpcResp { result }))
1866                }
1867            };
1868            if let Some(resp) = resp {
1869                if rpc_resp_tx.send(resp).is_err() {
1870                    return;
1871                }
1872            }
1873        }
1874    });
1875
1876    // Key thread
1877    let (key_tx, key_rx) = channel::unbounded();
1878    std::thread::spawn(move || loop {
1879        if let Ok(ev) = event::read() {
1880            if key_tx.send(ev).is_err() {
1881                return;
1882            }
1883        }
1884    });
1885
1886    // App state
1887    let mut app = App::new(all, &parent_route);
1888    if let Some(path) = &colors {
1889        if let Ok(theme) = load_theme(path) {
1890            app.view.theme = theme;
1891        } else {
1892            eprintln!("Failed to load theme");
1893        }
1894    }
1895
1896    let mut buffer = Buffer::new(100_000);
1897
1898    // UI
1899    let mut term = ratatui::init();
1900    let _ = term.hide_cursor();
1901    let ui_tick = channel::tick(Duration::from_millis(1000 / fps as u64));
1902
1903    'main: loop {
1904        crossbeam::select! {
1905            recv(data_rx) -> item => {
1906                match item {
1907                    Ok(TreeItem::Sample(sample, route)) => {
1908                        app.handle_sample(sample, route, &mut buffer);
1909                    }
1910                    Ok(TreeItem::Event(event)) => {
1911                        app.handle_event(event, &rpc_tx);
1912                    }
1913                    Err(_) => break 'main,
1914                }
1915            }
1916
1917            recv(key_rx) -> ev => {
1918                if let Ok(ev) = ev {
1919                    if let Some(act) = get_action(ev, &mut app) {
1920                        if app.update(act, &rpc_tx) {
1921                            break 'main;
1922                        }
1923                    }
1924                }
1925            }
1926
1927            recv(rpc_resp_rx) -> resp => {
1928                if let Ok(resp) = resp {
1929                    match resp {
1930                        RpcWorkerResp::List(list) => {
1931                            app.update_rpclists(list);
1932                        }
1933                        RpcWorkerResp::RpcResult(res) => {
1934                            let (msg, col) = match res.result {
1935                                Ok(s) => (format!("{}: {}", app.last_rpc_command, s), Color::Green),
1936                                Err(s) => (format!("ERR: {}", s), Color::Red),
1937                            };
1938                            app.last_rpc_result = Some((msg, col));
1939                        }
1940                    }
1941                }
1942            }
1943
1944            recv(ui_tick) -> _ => {
1945                app.update_plot_window(&buffer);
1946                app.rebuild_nav_items();
1947                app.tick_blink();
1948
1949                if draw_ui(&mut term, &mut app).is_err() {
1950                    break 'main;
1951                }
1952            }
1953        }
1954    }
1955
1956    ratatui::restore();
1957    Ok(())
1958}