use crossterm::{
ExecutableCommand,
event::{self, Event, KeyCode},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{prelude::*, widgets::*};
use rds_rs::{Decoder, Group, MAX_RADIOTEXT_LEN, PS_TEXT_LEN, RdsData, rds_to_utf8_lossy};
use rdspy::RdsGroupIterator;
use std::{
env,
fs::File,
io::{self, BufRead, BufReader, stdout},
path::Path,
};
const PS_LEN: usize = 2 * PS_TEXT_LEN;
const RADIOTEXT_LEN: usize = 2 * MAX_RADIOTEXT_LEN;
fn draw_ui(f: &mut Frame, rds_data: &RdsData, num: usize, max: usize) {
let area = f.area();
let title = format!(" RDS Viewer. Block {} of {} ", num, max);
let outer_block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray));
let inner_area = outer_block.inner(area);
f.render_widget(outer_block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner_area);
{
let rta_label = Paragraph::new("RTA:").style(Style::default().fg(Color::LightCyan));
let rta = rds_to_utf8_lossy::<RADIOTEXT_LEN>(&rds_data.rt.a.display);
let rta_content = format!(
"{:<64}",
rta.chars().take(MAX_RADIOTEXT_LEN).collect::<String>()
);
let rta_input = Paragraph::new(rta_content)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
let rta_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(4),
Constraint::Length(MAX_RADIOTEXT_LEN as u16),
Constraint::Min(0),
])
.split(chunks[0]);
f.render_widget(rta_label, rta_area[0]);
f.render_widget(rta_input, rta_area[1]);
}
{
let label = Paragraph::new("RTB:").style(Style::default().fg(Color::LightCyan));
let rtb = rds_to_utf8_lossy::<RADIOTEXT_LEN>(&rds_data.rt.b.display);
let rtb_content = format!(
"{:<64}",
rtb.chars().take(MAX_RADIOTEXT_LEN).collect::<String>()
);
let rtb_input = Paragraph::new(rtb_content)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
let rtb_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(4),
Constraint::Length(MAX_RADIOTEXT_LEN as u16),
Constraint::Min(0),
])
.split(chunks[1]);
f.render_widget(label, rtb_area[0]);
f.render_widget(rtb_input, rtb_area[1]);
}
{
const PTYN_LEN: u8 = 8;
let label = Paragraph::new("PTYN:").style(Style::default().fg(Color::LightCyan));
let data = rds_to_utf8_lossy::<PS_LEN>(&rds_data.ptyn.display);
let text = format!("{:<8}", data.chars().take(8).collect::<String>());
let input =
Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
let area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(5),
Constraint::Length(PTYN_LEN as u16),
Constraint::Min(0),
])
.split(chunks[2]);
f.render_widget(label, area[0]);
f.render_widget(input, area[1]);
}
{
let label = Paragraph::new("PS:").style(Style::default().fg(Color::LightCyan));
let data = rds_to_utf8_lossy::<PS_LEN>(&rds_data.tn.ps.display);
let text = format!("{:<8}", data.chars().take(8).collect::<String>());
let input =
Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
let area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(3),
Constraint::Length(PS_LEN as u16),
Constraint::Min(0),
])
.split(chunks[2]);
f.render_widget(label, area[0]);
f.render_widget(input, area[1]);
}
}
fn main() -> io::Result<()> {
let args: Vec<String> = env::args().collect();
let rds_blocks: Vec<RdsData>;
match args.len() {
1 => {
println!("Reading RDS groups from stdin...");
rds_blocks = process_reader(BufReader::new(io::stdin()))?;
}
2 => {
let path = Path::new(&args[1]);
if path.is_file() {
let file = File::open(path)?;
rds_blocks = process_reader(BufReader::new(file))?;
} else {
eprintln!("Error: '{}' is not a file", path.display());
std::process::exit(1);
}
}
_ => {
eprintln!("Usage: {} [path]", args[0]);
eprintln!(" path can be:");
eprintln!(" - omitted → read from stdin");
eprintln!(" - a file → process single .rds / .spy file");
std::process::exit(1);
}
}
if rds_blocks.is_empty() {
println!("No RDS data can be decoded from the file.");
return Ok(());
}
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
let mut block_idx: usize = 0;
loop {
let rds_data = rds_blocks.get(block_idx).unwrap();
terminal.draw(|f| {
draw_ui(f, rds_data, block_idx + 1, rds_blocks.len());
})?;
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => {
break;
}
KeyCode::Left => {
block_idx = block_idx.saturating_sub(1);
}
KeyCode::Right if block_idx + 1 < rds_blocks.len() => {
block_idx += 1;
}
_ => {}
}
}
}
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
fn process_reader<R: BufRead + 'static>(reader: R) -> io::Result<Vec<RdsData>> {
let mut rds_blocks: Vec<RdsData> = Vec::new();
let mut rds_data = RdsData::default();
let mut decoder = Decoder::new(false);
for group_result in RdsGroupIterator::new(reader) {
match group_result {
Ok(group) => {
let blocks = Group {
a: group.a,
b: group.b,
c: group.c,
d: group.d,
};
decoder.decode(&blocks, &mut rds_data);
rds_blocks.push(rds_data.clone());
}
Err(e) => eprintln!("Error: {}", e),
}
}
Ok(rds_blocks)
}