thd75_tui/ui/
aprs.rs

1use std::time::Instant;
2
3use ratatui::Frame;
4use ratatui::layout::Rect;
5use ratatui::style::{Color, Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Block, Borders, Paragraph};
8
9use crate::app::{App, AprsMessageState, AprsMode, McpState, Pane};
10
11/// Format a duration since `then` as a human-readable "ago" string.
12fn ago(then: Instant) -> String {
13    let secs = then.elapsed().as_secs();
14    if secs < 60 {
15        format!("{secs}s ago")
16    } else if secs < 3600 {
17        format!("{}m ago", secs / 60)
18    } else {
19        format!("{}h ago", secs / 3600)
20    }
21}
22
23/// Format latitude/longitude for display.
24fn fmt_lat(lat: f64) -> String {
25    let ns = if lat >= 0.0 { 'N' } else { 'S' };
26    format!("{:.3}\u{00b0}{ns}", lat.abs())
27}
28
29fn fmt_lon(lon: f64) -> String {
30    let ew = if lon >= 0.0 { 'E' } else { 'W' };
31    format!("{:.3}\u{00b0}{ew}", lon.abs())
32}
33
34fn kv_line(label: &str, value: String, value_color: Color) -> Line<'_> {
35    Line::from(vec![
36        Span::styled(
37            format!("  {label:<22}"),
38            Style::default().fg(Color::DarkGray),
39        ),
40        Span::styled(value, Style::default().fg(value_color)),
41    ])
42}
43
44pub(crate) fn render(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
45    match app.aprs_mode {
46        AprsMode::Active => render_live(app, frame, list_area, detail_area),
47        AprsMode::Inactive => render_mcp_config(app, frame, list_area, detail_area),
48    }
49}
50
51// ---------------------------------------------------------------------------
52// Live APRS view (KISS mode active)
53// ---------------------------------------------------------------------------
54
55fn render_live(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
56    // --- Left pane: station list ---
57    let station_count = app.aprs_stations.len();
58    let title = format!(" APRS Stations ({station_count} heard) ");
59
60    let block = Block::default()
61        .title(title)
62        .borders(Borders::ALL)
63        .border_style(super::border_style(app, Pane::Main));
64
65    let mut lines: Vec<Line<'_>> = Vec::new();
66
67    if app.aprs_stations.is_empty() {
68        lines.push(Line::from(Span::styled(
69            "  Listening...",
70            Style::default().fg(Color::DarkGray),
71        )));
72    } else {
73        // Compute visible range for scrolling.
74        let visible_height = list_area.height.saturating_sub(2) as usize; // borders
75        let start = if app.aprs_station_index >= visible_height {
76            app.aprs_station_index - visible_height + 1
77        } else {
78            0
79        };
80        let end = (start + visible_height).min(station_count);
81
82        for (i, station) in app.aprs_stations[start..end].iter().enumerate() {
83            let idx = start + i;
84            let is_selected = idx == app.aprs_station_index;
85
86            let callsign = format!("{:<10}", station.callsign);
87            let pos = match (station.latitude, station.longitude) {
88                (Some(lat), Some(lon)) => format!("{} {}", fmt_lat(lat), fmt_lon(lon)),
89                _ => "No position".to_string(),
90            };
91            let time = ago(station.last_heard);
92
93            let style = if is_selected {
94                Style::default()
95                    .fg(Color::Cyan)
96                    .add_modifier(Modifier::BOLD)
97            } else {
98                Style::default().fg(Color::White)
99            };
100
101            let marker = if is_selected { ">" } else { " " };
102            lines.push(Line::from(Span::styled(
103                format!(" {marker} {callsign} {pos:<26} {time}"),
104                style,
105            )));
106        }
107    }
108
109    // Compose prompt overlay
110    if let Some(ref buf) = app.aprs_compose {
111        lines.push(Line::from(""));
112        let target = app
113            .aprs_stations
114            .get(app.aprs_station_index)
115            .map_or("?", |s| s.callsign.as_str());
116        lines.push(Line::from(vec![
117            Span::styled(
118                format!("  Msg to {target}: "),
119                Style::default().fg(Color::Yellow),
120            ),
121            Span::styled(format!("{buf}_"), Style::default().fg(Color::White)),
122        ]));
123    }
124
125    // Footer hint
126    lines.push(Line::from(""));
127    lines.push(Line::from(Span::styled(
128        " [j/k] Navigate  [M] Message  [b] Beacon  [a] Stop APRS",
129        Style::default().fg(Color::DarkGray),
130    )));
131
132    frame.render_widget(Paragraph::new(lines).block(block), list_area);
133
134    // --- Right pane: station detail + messages ---
135    let detail_block = Block::default()
136        .title(" Station Detail ")
137        .borders(Borders::ALL)
138        .border_style(super::border_style(app, Pane::Detail));
139
140    let mut detail_lines: Vec<Line<'_>> = Vec::new();
141
142    if let Some(station) = app.aprs_stations.get(app.aprs_station_index) {
143        detail_lines.push(Line::from(Span::styled(
144            format!(" {}", station.callsign),
145            Style::default()
146                .fg(Color::Cyan)
147                .add_modifier(Modifier::BOLD),
148        )));
149        detail_lines.push(Line::from(""));
150
151        // Position
152        match (station.latitude, station.longitude) {
153            (Some(lat), Some(lon)) => {
154                detail_lines.push(kv_line(
155                    "Position",
156                    format!("{} {}", fmt_lat(lat), fmt_lon(lon)),
157                    Color::White,
158                ));
159            }
160            _ => {
161                detail_lines.push(kv_line("Position", "Unknown".into(), Color::DarkGray));
162            }
163        }
164
165        // Speed / course
166        if let Some(speed) = station.speed_knots {
167            let mph = f64::from(speed) * 1.15078;
168            let course_str = station
169                .course_degrees
170                .map_or(String::new(), |c| format!(" heading {c}\u{00b0}"));
171            detail_lines.push(kv_line(
172                "Speed",
173                format!("{mph:.0} mph{course_str}"),
174                Color::White,
175            ));
176        }
177
178        // Last heard
179        detail_lines.push(kv_line("Last heard", ago(station.last_heard), Color::White));
180
181        // Packet count
182        detail_lines.push(kv_line(
183            "Packets",
184            station.packet_count.to_string(),
185            Color::White,
186        ));
187
188        // Digipeater path
189        if !station.last_path.is_empty() {
190            detail_lines.push(kv_line("Path", station.last_path.join(","), Color::White));
191        }
192
193        // Symbol
194        if let (Some(tbl), Some(code)) = (station.symbol_table, station.symbol_code) {
195            detail_lines.push(kv_line("Symbol", format!("{tbl}{code}"), Color::White));
196        }
197
198        // Comment
199        if let Some(ref comment) = station.comment {
200            detail_lines.push(kv_line("Comment", comment.clone(), Color::White));
201        }
202
203        // --- Messages section ---
204        let pending = app
205            .aprs_messages
206            .iter()
207            .filter(|m| m.state == AprsMessageState::Pending)
208            .count();
209        let delivered = app
210            .aprs_messages
211            .iter()
212            .filter(|m| m.state == AprsMessageState::Delivered)
213            .count();
214
215        if !app.aprs_messages.is_empty() {
216            detail_lines.push(Line::from(""));
217            detail_lines.push(Line::from(Span::styled(
218                format!(" Messages ({pending} pending, {delivered} delivered):"),
219                Style::default().fg(Color::Yellow),
220            )));
221
222            // Show most recent messages (up to 10).
223            for msg in app.aprs_messages.iter().rev().take(10) {
224                let (status_str, color) = match msg.state {
225                    AprsMessageState::Pending => ("...", Color::Yellow),
226                    AprsMessageState::Delivered => ("ack", Color::Green),
227                    AprsMessageState::Rejected => ("rej", Color::Red),
228                    AprsMessageState::Expired => ("exp", Color::Red),
229                };
230                detail_lines.push(Line::from(vec![
231                    Span::styled(
232                        format!("  -> {}: ", msg.addressee),
233                        Style::default().fg(Color::DarkGray),
234                    ),
235                    Span::styled(msg.text.clone(), Style::default().fg(Color::White)),
236                    Span::styled(format!(" [{status_str}]"), Style::default().fg(color)),
237                ]));
238            }
239        }
240    } else {
241        detail_lines.push(Line::from(Span::styled(
242            " No station selected",
243            Style::default().fg(Color::DarkGray),
244        )));
245    }
246
247    frame.render_widget(
248        Paragraph::new(detail_lines).block(detail_block),
249        detail_area,
250    );
251}
252
253// ---------------------------------------------------------------------------
254// MCP config view (KISS not active)
255// ---------------------------------------------------------------------------
256
257fn render_mcp_config(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
258    let block = Block::default()
259        .title(" APRS ")
260        .borders(Borders::ALL)
261        .border_style(super::border_style(app, Pane::Main));
262
263    let detail_block = Block::default()
264        .title(" APRS Region Info ")
265        .borders(Borders::ALL)
266        .border_style(super::border_style(app, Pane::Detail));
267
268    let McpState::Loaded { ref image, .. } = app.mcp else {
269        frame.render_widget(
270            Paragraph::new(vec![
271                Line::from(" No MCP data loaded."),
272                Line::from(" Press [m] then [r] to read from radio."),
273                Line::from(""),
274                Line::from(Span::styled(
275                    " Press [a] to enter live APRS mode.",
276                    Style::default().fg(Color::Cyan),
277                )),
278            ])
279            .block(block),
280            list_area,
281        );
282        frame.render_widget(Paragraph::new("").block(detail_block), detail_area);
283        return;
284    };
285
286    let aprs = image.aprs();
287
288    // -------------------------------------------------------------------------
289    // Left pane: APRS config fields
290    // -------------------------------------------------------------------------
291    let mut lines: Vec<Line<'_>> = Vec::new();
292
293    lines.push(Line::from(Span::styled(
294        " APRS Configuration",
295        Style::default().fg(Color::Yellow),
296    )));
297    lines.push(Line::from(""));
298
299    {
300        let cs = aprs.my_callsign();
301        let (disp, col) = if cs.is_empty() {
302            ("<not set>".into(), Color::DarkGray)
303        } else {
304            (cs, Color::Cyan)
305        };
306        lines.push(kv_line("My callsign", disp, col));
307    }
308
309    {
310        let interval = aprs.beacon_interval();
311        let (disp, col) = if interval == 0 {
312            ("Off".into(), Color::DarkGray)
313        } else {
314            (format!("{interval} s"), Color::White)
315        };
316        lines.push(kv_line("Beacon interval", disp, col));
317    }
318
319    lines.push(kv_line("Packet path", aprs.packet_path(), Color::White));
320
321    lines.push(Line::from(""));
322    lines.push(Line::from(Span::styled(
323        " Press [a] to enter live APRS mode.",
324        Style::default().fg(Color::Cyan),
325    )));
326
327    frame.render_widget(Paragraph::new(lines).block(block), list_area);
328
329    // -------------------------------------------------------------------------
330    // Right pane: APRS region info
331    // -------------------------------------------------------------------------
332    let mut detail_lines: Vec<Line<'_>> = Vec::new();
333
334    detail_lines.push(Line::from(Span::styled(
335        " APRS Memory Regions",
336        Style::default().fg(Color::Yellow),
337    )));
338    detail_lines.push(Line::from(""));
339
340    let region_info = [
341        ("Status header", "0x15100", "256 bytes"),
342        ("Data / settings", "0x15200", "~16 KB"),
343        ("Position data", "0x25100", "19,200 bytes (confirmed)"),
344    ];
345
346    for (name, offset, size) in region_info {
347        detail_lines.push(Line::from(vec![
348            Span::styled(
349                format!("  {name:<18}"),
350                Style::default().fg(Color::DarkGray),
351            ),
352            Span::styled(
353                format!("{offset}  {size}"),
354                Style::default().fg(Color::White),
355            ),
356        ]));
357    }
358
359    detail_lines.push(Line::from(""));
360
361    let has_pos = aprs.has_position_data();
362    let (pos_str, pos_col) = if has_pos {
363        ("Yes", Color::Cyan)
364    } else {
365        ("No", Color::DarkGray)
366    };
367    detail_lines.push(Line::from(vec![
368        Span::styled(
369            "  Position data present  ",
370            Style::default().fg(Color::DarkGray),
371        ),
372        Span::styled(pos_str, Style::default().fg(pos_col)),
373    ]));
374
375    let region_sz = aprs.region_size();
376    detail_lines.push(Line::from(vec![
377        Span::styled(
378            "  Total region size      ",
379            Style::default().fg(Color::DarkGray),
380        ),
381        Span::styled(
382            format!("{region_sz} bytes"),
383            Style::default().fg(Color::White),
384        ),
385    ]));
386
387    frame.render_widget(
388        Paragraph::new(detail_lines).block(detail_block),
389        detail_area,
390    );
391}