Skip to main content

wg_tui/app/
render.rs

1use ratatui::{
2    Frame,
3    layout::{Constraint, Layout, Rect},
4    style::{Color, Modifier, Style, Stylize},
5    text::{Line, Text},
6    widgets::{List, ListItem, Paragraph, Wrap},
7};
8
9use crate::ui::{
10    bordered_block, label, peer_lines, render_add_menu, render_confirm, render_full_tunnel_warning,
11    render_help, render_import_conflict, render_input, render_peer_config, render_peer_qr, section,
12    truncate_key,
13};
14
15use super::App;
16
17impl App {
18    pub fn draw(&mut self, frame: &mut Frame) {
19        let chunks = Layout::horizontal(if self.flags.show_details() {
20            vec![Constraint::Percentage(40), Constraint::Percentage(60)]
21        } else {
22            vec![Constraint::Percentage(100)]
23        })
24        .split(frame.area());
25
26        self.render_main(frame, chunks[0]);
27        if self.flags.show_details() && chunks.len() > 1 {
28            self.render_details(frame, chunks[1]);
29        }
30        self.render_overlays(frame);
31    }
32
33    fn render_main(&mut self, frame: &mut Frame, area: Rect) {
34        let main = Layout::vertical([
35            Constraint::Length(3),
36            Constraint::Min(0),
37            Constraint::Length(3),
38        ])
39        .split(area);
40
41        Self::render_header(frame, main[0]);
42        self.render_list(frame, main[1]);
43        self.render_status(frame, main[2]);
44    }
45
46    fn render_overlays(&mut self, frame: &mut Frame) {
47        if self.flags.show_help() {
48            render_help(frame);
49        }
50        if self.flags.confirm_delete()
51            && let Some(tunnel) = self.selected()
52        {
53            render_confirm(frame, &tunnel.name);
54        }
55        if let Some(ref pending) = self.pending_import {
56            render_import_conflict(frame, pending.conflicts);
57        }
58        if let Some(ref name) = self.confirm_full_tunnel {
59            render_full_tunnel_warning(frame, name);
60        }
61        if self.flags.show_add_menu() {
62            render_add_menu(frame);
63        }
64        self.render_path_inputs(frame);
65        self.render_wizard_input(frame);
66        self.render_peer_input(frame);
67        self.render_peer_output(frame);
68    }
69
70    fn render_path_inputs(&mut self, frame: &mut Frame) {
71        if let Some(ref path) = self.input_path {
72            let cwd = std::env::current_dir()
73                .map(|p| format!("cwd: {}  (use ~/ for home)", p.display()))
74                .ok();
75            render_input(
76                frame,
77                "Import Tunnel",
78                "File path (.conf):",
79                path,
80                cwd.as_deref(),
81            );
82        }
83        if let Some(ref path) = self.input_zip {
84            let cwd = std::env::current_dir()
85                .map(|p| format!("cwd: {}  (use ~/ for home)", p.display()))
86                .ok();
87            render_input(
88                frame,
89                "Import Zip",
90                "Zip path (.zip):",
91                path,
92                cwd.as_deref(),
93            );
94        }
95        if let Some(ref path) = self.export_path {
96            let hint = std::env::current_dir()
97                .map(|p| {
98                    format!(
99                        "{} tunnel(s) — cwd: {}  (use ~/ for home)",
100                        self.tunnels.len(),
101                        p.display()
102                    )
103                })
104                .ok();
105            render_input(
106                frame,
107                "Export All Tunnels",
108                "Destination (.zip):",
109                path,
110                hint.as_deref(),
111            );
112        }
113    }
114
115    fn render_wizard_input(&mut self, frame: &mut Frame) {
116        if let Some(ref wizard) = self.new_tunnel {
117            let (title, prompt, hint) = wizard.ui();
118            render_input(
119                frame,
120                &title,
121                prompt,
122                wizard.current_value(),
123                hint.as_deref(),
124            );
125        }
126    }
127
128    fn render_peer_input(&mut self, frame: &mut Frame) {
129        if let Some(ref endpoint) = self.peer_endpoint_input {
130            render_input(
131                frame,
132                "Peer Endpoint",
133                "Endpoint (host:port):",
134                endpoint,
135                Some("Confirm or edit the server address"),
136            );
137        }
138        if let Some(ref dns) = self.peer_dns_input {
139            render_input(
140                frame,
141                "Peer DNS",
142                "DNS (optional):",
143                dns,
144                Some("Leave empty to skip"),
145            );
146        }
147    }
148
149    fn render_peer_output(&mut self, frame: &mut Frame) {
150        if let Some(ref peer) = self.peer_config {
151            if peer.show_qr {
152                if let Some(code) = peer.qr_code.as_ref() {
153                    render_peer_qr(frame, code);
154                } else {
155                    render_peer_config(frame, &peer.config_text, &peer.suggested_path);
156                }
157            } else {
158                render_peer_config(frame, &peer.config_text, &peer.suggested_path);
159            }
160        }
161        if let Some(ref path) = self.peer_save_path {
162            render_input(
163                frame,
164                "Save Peer Config",
165                "Destination (.conf):",
166                path,
167                Some("Press Enter to save"),
168            );
169        }
170    }
171
172    fn render_header(f: &mut Frame, area: Rect) {
173        let title = Line::from(vec![
174            " WireGuard ".fg(Color::Cyan).bold(),
175            "TUI Manager".fg(Color::White),
176        ]);
177        f.render_widget(Paragraph::new(title).block(bordered_block(None)), area);
178    }
179
180    fn render_list(&mut self, f: &mut Frame, area: Rect) {
181        let items: Vec<ListItem> = self
182            .tunnels
183            .iter()
184            .map(|t| {
185                let (icon, color) = if t.is_active {
186                    ("●", Color::Green)
187                } else {
188                    ("○", Color::DarkGray)
189                };
190                ListItem::new(Line::from(vec![
191                    format!(" {icon} ").fg(color),
192                    t.name.clone().fg(Color::White),
193                ]))
194            })
195            .collect();
196
197        let list = List::new(items)
198            .block(bordered_block(Some(" Tunnels ")))
199            .highlight_style(
200                Style::default()
201                    .bg(Color::DarkGray)
202                    .add_modifier(Modifier::BOLD),
203            )
204            .highlight_symbol("▶ ");
205
206        f.render_stateful_widget(list, area, &mut self.list_state);
207    }
208
209    fn render_status(&self, f: &mut Frame, area: Rect) {
210        let content = match &self.message {
211            Some(msg) => Line::styled(format!(" {}", msg.text()), msg.style()),
212            None => Line::from(vec![
213                " j/k".fg(Color::Yellow),
214                " nav  ".into(),
215                "Enter".fg(Color::Yellow),
216                " toggle  ".into(),
217                "d".fg(Color::Yellow),
218                " details  ".into(),
219                "?".fg(Color::Yellow),
220                " help  ".into(),
221                "q".fg(Color::Yellow),
222                " quit".into(),
223            ]),
224        };
225        f.render_widget(Paragraph::new(content).block(bordered_block(None)), area);
226    }
227
228    fn render_details(&self, f: &mut Frame, area: Rect) {
229        let Some(tunnel) = self.selected() else {
230            f.render_widget(
231                Paragraph::new(" No tunnel selected")
232                    .fg(Color::DarkGray)
233                    .block(bordered_block(Some(" Details "))),
234                area,
235            );
236            return;
237        };
238
239        let mut lines = vec![
240            label("Name: ", &tunnel.name),
241            label("Config: ", &tunnel.config_path.display().to_string()),
242            Line::from(vec![
243                "Status: ".fg(Color::Yellow),
244                if tunnel.is_active {
245                    "Active".fg(Color::Green)
246                } else {
247                    "Inactive".fg(Color::Red)
248                },
249            ]),
250            Line::raw(""),
251        ];
252
253        if let Some(iface) = &tunnel.interface {
254            lines.push(section("Interface"));
255            if !iface.public_key.is_empty() {
256                lines.push(label("Public Key: ", &truncate_key(&iface.public_key)));
257            }
258            if let Some(port) = iface.listen_port {
259                lines.push(label("Listen Port: ", &port.to_string()));
260            }
261
262            for (i, peer) in iface.peers.iter().enumerate() {
263                lines.push(Line::raw(""));
264                if i == 0 {
265                    lines.push(section(&format!("Peers ({})", iface.peers.len())));
266                }
267                lines.extend(peer_lines(peer));
268            }
269        }
270
271        f.render_widget(
272            Paragraph::new(Text::from(lines))
273                .block(bordered_block(Some(" Details ")))
274                .wrap(Wrap { trim: false }),
275            area,
276        );
277    }
278}