thd75_tui/ui/
dstar.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, DStarMode, 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
23fn kv_line(label: &str, value: String, value_color: Color) -> Line<'_> {
24    Line::from(vec![
25        Span::styled(
26            format!("  {label:<18}"),
27            Style::default().fg(Color::DarkGray),
28        ),
29        Span::styled(value, Style::default().fg(value_color)),
30    ])
31}
32
33/// Format a D-STAR callsign for display, splitting the 8-char field into
34/// a trimmed callsign and module letter if present.
35fn fmt_callsign(cs: &str) -> String {
36    let trimmed = cs.trim();
37    if trimmed.is_empty() {
38        "<not set>".to_string()
39    } else {
40        trimmed.to_string()
41    }
42}
43
44pub(crate) fn render(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
45    match app.dstar_mode {
46        DStarMode::Active => render_gateway(app, frame, list_area, detail_area),
47        DStarMode::Inactive => render_cat_config(app, frame, list_area, detail_area),
48    }
49}
50
51// ---------------------------------------------------------------------------
52// CAT config view (gateway not active)
53// ---------------------------------------------------------------------------
54
55fn render_cat_config(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
56    let block = Block::default()
57        .title(" D-STAR Configuration ")
58        .borders(Borders::ALL)
59        .border_style(super::border_style(app, Pane::Main));
60
61    let detail_block = Block::default()
62        .title(" Quick Actions ")
63        .borders(Borders::ALL)
64        .border_style(super::border_style(app, Pane::Detail));
65
66    let mut lines: Vec<Line<'_>> = Vec::new();
67
68    // Show MCP-based MY callsign if available
69    if let McpState::Loaded { ref image, .. } = app.mcp {
70        let my_cs = image.dstar().my_callsign();
71        let (disp, col) = if my_cs.is_empty() {
72            ("<not set>".into(), Color::DarkGray)
73        } else {
74            (my_cs, Color::Cyan)
75        };
76        lines.push(kv_line("MY Callsign", disp, col));
77        lines.push(Line::from(""));
78    }
79
80    // URCALL from CAT poll
81    let urcall_disp = fmt_callsign(&app.state.dstar_urcall);
82    let urcall_suffix = app.state.dstar_urcall_suffix.trim().to_string();
83    let urcall_full = if urcall_suffix.is_empty() {
84        urcall_disp.clone()
85    } else {
86        format!("{urcall_disp}  {urcall_suffix}")
87    };
88    let urcall_color = if urcall_disp == "<not set>" {
89        Color::DarkGray
90    } else {
91        Color::Cyan
92    };
93    lines.push(kv_line("URCALL", urcall_full, urcall_color));
94
95    // RPT1
96    let rpt1_disp = fmt_callsign(&app.state.dstar_rpt1);
97    let rpt1_color = if rpt1_disp == "<not set>" {
98        Color::DarkGray
99    } else {
100        Color::White
101    };
102    lines.push(kv_line("RPT1", rpt1_disp, rpt1_color));
103
104    // RPT2
105    let rpt2_disp = fmt_callsign(&app.state.dstar_rpt2);
106    let rpt2_color = if rpt2_disp == "<not set>" {
107        Color::DarkGray
108    } else {
109        Color::White
110    };
111    lines.push(kv_line("RPT2", rpt2_disp, rpt2_color));
112
113    lines.push(Line::from(""));
114
115    // Gateway mode
116    let gw_str = app
117        .state
118        .dstar_gateway_mode
119        .map_or_else(|| "Unknown".to_string(), |g| format!("{g:?}"));
120    lines.push(kv_line("Gateway Mode", gw_str, Color::White));
121
122    // D-STAR slot
123    let slot_str = app
124        .state
125        .dstar_slot
126        .map_or_else(|| "Unknown".to_string(), |s| format!("{}", s.as_u8()));
127    lines.push(kv_line("D-STAR Slot", slot_str, Color::White));
128
129    // Callsign slot
130    let cs_slot_str = app
131        .state
132        .dstar_callsign_slot
133        .map_or_else(|| "Unknown".to_string(), |s| format!("{}", s.as_u8()));
134    lines.push(kv_line("Callsign Slot", cs_slot_str, Color::White));
135
136    // Input prompts
137    lines.push(Line::from(""));
138    if let Some(ref buf) = app.dstar_urcall_input {
139        lines.push(Line::from(vec![
140            Span::styled("  URCALL: ", Style::default().fg(Color::Yellow)),
141            Span::styled(format!("{buf}_"), Style::default().fg(Color::White)),
142        ]));
143    } else if let Some(ref buf) = app.dstar_reflector_input {
144        lines.push(Line::from(vec![
145            Span::styled(
146                "  Reflector (e.g. REF030 C): ",
147                Style::default().fg(Color::Yellow),
148            ),
149            Span::styled(format!("{buf}_"), Style::default().fg(Color::White)),
150        ]));
151    } else {
152        lines.push(Line::from(Span::styled(
153            " [d] Enter Gateway Mode  [u] Set URCALL",
154            Style::default().fg(Color::DarkGray),
155        )));
156        lines.push(Line::from(Span::styled(
157            " [r] Connect Reflector   [U] Unlink Reflector",
158            Style::default().fg(Color::DarkGray),
159        )));
160    }
161
162    frame.render_widget(Paragraph::new(lines).block(block), list_area);
163
164    // --- Right pane: quick actions ---
165    let mut detail_lines: Vec<Line<'_>> = Vec::new();
166
167    detail_lines.push(Line::from(Span::styled(
168        " D-STAR Quick Actions",
169        Style::default().fg(Color::Yellow),
170    )));
171    detail_lines.push(Line::from(""));
172
173    let actions = [
174        ("[C]", "CQ (set URCALL to CQCQCQ)"),
175        ("[r]", "Connect to reflector"),
176        ("[U]", "Unlink from reflector"),
177        ("[u]", "Set URCALL manually"),
178        ("[d]", "Enter gateway mode (MMDVM)"),
179    ];
180
181    for (key, desc) in actions {
182        detail_lines.push(Line::from(vec![
183            Span::styled(format!("  {key:<6}"), Style::default().fg(Color::Yellow)),
184            Span::styled(desc.to_string(), Style::default().fg(Color::White)),
185        ]));
186    }
187
188    // MCP D-STAR info if available
189    if let McpState::Loaded { ref image, .. } = app.mcp {
190        let dstar = image.dstar();
191        let rpt_count = dstar.repeater_count();
192
193        detail_lines.push(Line::from(""));
194        detail_lines.push(Line::from(Span::styled(
195            " MCP D-STAR Data",
196            Style::default().fg(Color::Yellow),
197        )));
198        detail_lines.push(Line::from(""));
199        detail_lines.push(kv_line("Repeaters", format!("{rpt_count}"), Color::White));
200
201        let region_sz = dstar.region_size();
202        detail_lines.push(kv_line(
203            "Region Size",
204            format!("{region_sz} bytes"),
205            Color::White,
206        ));
207    }
208
209    frame.render_widget(
210        Paragraph::new(detail_lines).block(detail_block),
211        detail_area,
212    );
213}
214
215// ---------------------------------------------------------------------------
216// Gateway mode view (DStarGateway active)
217// ---------------------------------------------------------------------------
218
219fn render_gateway(app: &App, frame: &mut Frame<'_>, list_area: Rect, detail_area: Rect) {
220    // --- Left pane: last heard list ---
221    let count = app.dstar_last_heard.len();
222    let title = format!(" D-STAR Gateway ({count} heard) ");
223
224    let block = Block::default()
225        .title(title)
226        .borders(Borders::ALL)
227        .border_style(super::border_style(app, Pane::Main));
228
229    let mut lines: Vec<Line<'_>> = Vec::new();
230
231    if app.dstar_last_heard.is_empty() {
232        lines.push(Line::from(Span::styled(
233            "  Listening...",
234            Style::default().fg(Color::DarkGray),
235        )));
236    } else {
237        let visible_height = list_area.height.saturating_sub(4) as usize;
238        let start = if app.dstar_last_heard_index >= visible_height {
239            app.dstar_last_heard_index - visible_height + 1
240        } else {
241            0
242        };
243        let end = (start + visible_height).min(count);
244
245        for (i, entry) in app.dstar_last_heard[start..end].iter().enumerate() {
246            let idx = start + i;
247            let is_selected = idx == app.dstar_last_heard_index;
248
249            let callsign = format!("{:<9}", entry.callsign.trim());
250            let dest = format!("{:<9}", entry.destination.trim());
251            let time = ago(entry.timestamp);
252
253            let style = if is_selected {
254                Style::default()
255                    .fg(Color::Cyan)
256                    .add_modifier(Modifier::BOLD)
257            } else {
258                Style::default().fg(Color::White)
259            };
260
261            let marker = if is_selected { ">" } else { " " };
262            lines.push(Line::from(Span::styled(
263                format!(" {marker} {callsign} -> {dest} {time}"),
264                style,
265            )));
266        }
267    }
268
269    lines.push(Line::from(""));
270    lines.push(Line::from(Span::styled(
271        " [j/k] Navigate  [d] Exit Gateway Mode",
272        Style::default().fg(Color::DarkGray),
273    )));
274
275    frame.render_widget(Paragraph::new(lines).block(block), list_area);
276
277    // --- Right pane: detail ---
278    let detail_block = Block::default()
279        .title(" Current Transmission ")
280        .borders(Borders::ALL)
281        .border_style(super::border_style(app, Pane::Detail));
282
283    let mut detail_lines: Vec<Line<'_>> = Vec::new();
284
285    if let Some(ref header) = app.dstar_rx_header {
286        let status_str = if app.dstar_rx_active {
287            "Receiving voice..."
288        } else {
289            "Idle"
290        };
291        let status_color = if app.dstar_rx_active {
292            Color::Green
293        } else {
294            Color::DarkGray
295        };
296
297        detail_lines.push(kv_line(
298            "From",
299            format!("{} {}", header.my_call.as_str(), header.my_suffix.as_str()),
300            Color::Cyan,
301        ));
302        detail_lines.push(kv_line(
303            "To",
304            header.ur_call.as_str().into_owned(),
305            Color::White,
306        ));
307        detail_lines.push(kv_line(
308            "RPT1",
309            header.rpt1.as_str().into_owned(),
310            Color::White,
311        ));
312        detail_lines.push(kv_line(
313            "RPT2",
314            header.rpt2.as_str().into_owned(),
315            Color::White,
316        ));
317        detail_lines.push(kv_line("Status", status_str.to_string(), status_color));
318
319        if let Some(ref text) = app.dstar_text_message {
320            detail_lines.push(Line::from(""));
321            detail_lines.push(kv_line("Text Message", text.clone(), Color::Yellow));
322        }
323    } else if let Some(entry) = app.dstar_last_heard.get(app.dstar_last_heard_index) {
324        // Show selected station info when no active transmission
325        detail_lines.push(Line::from(Span::styled(
326            format!(" {}", entry.callsign.trim()),
327            Style::default()
328                .fg(Color::Cyan)
329                .add_modifier(Modifier::BOLD),
330        )));
331        detail_lines.push(Line::from(""));
332
333        let suffix = entry.suffix.trim();
334        if !suffix.is_empty() {
335            detail_lines.push(kv_line("Suffix", suffix.to_string(), Color::White));
336        }
337        detail_lines.push(kv_line(
338            "Destination",
339            entry.destination.trim().to_string(),
340            Color::White,
341        ));
342        detail_lines.push(kv_line(
343            "RPT1",
344            entry.repeater1.trim().to_string(),
345            Color::White,
346        ));
347        detail_lines.push(kv_line(
348            "RPT2",
349            entry.repeater2.trim().to_string(),
350            Color::White,
351        ));
352        detail_lines.push(kv_line("Last heard", ago(entry.timestamp), Color::White));
353    } else {
354        detail_lines.push(Line::from(Span::styled(
355            " No transmission yet",
356            Style::default().fg(Color::DarkGray),
357        )));
358    }
359
360    frame.render_widget(
361        Paragraph::new(detail_lines).block(detail_block),
362        detail_area,
363    );
364}