Skip to main content

romm_cli/tui/screens/
execute.rs

1use ratatui::layout::{Constraint, Direction, Layout, Rect};
2use ratatui::widgets::{Block, Borders, Paragraph};
3use ratatui::Frame;
4
5use crate::tui::openapi::ApiEndpoint;
6
7/// Screen for editing path/query/body parameters and executing a single endpoint.
8pub struct ExecuteScreen {
9    pub endpoint: ApiEndpoint,
10    pub path_params: Vec<(String, String)>,
11    pub path_param_input_idx: usize,
12    pub query_params: Vec<(String, String)>,
13    pub body_text: String,
14    pub focused_field: FocusedField,
15    pub param_input_idx: usize,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum FocusedField {
20    PathParams,
21    QueryParams,
22    Body,
23}
24
25struct MiddleAreas {
26    path: Option<Rect>,
27    query: Rect,
28    body: Option<Rect>,
29}
30
31impl ExecuteScreen {
32    pub fn new(endpoint: ApiEndpoint) -> Self {
33        let path_params: Vec<(String, String)> = endpoint
34            .path_params
35            .iter()
36            .map(|p| (p.name.clone(), p.default.clone().unwrap_or_default()))
37            .collect();
38
39        let query_params: Vec<(String, String)> = endpoint
40            .query_params
41            .iter()
42            .map(|p| (p.name.clone(), p.default.clone().unwrap_or_default()))
43            .collect();
44
45        let focused_field = if !path_params.is_empty() {
46            FocusedField::PathParams
47        } else {
48            FocusedField::QueryParams
49        };
50
51        Self {
52            endpoint,
53            path_params,
54            path_param_input_idx: 0,
55            query_params,
56            body_text: String::new(),
57            focused_field,
58            param_input_idx: 0,
59        }
60    }
61
62    pub fn get_path_params(&self) -> Vec<(String, String)> {
63        self.path_params
64            .iter()
65            .filter(|(_, v)| !v.is_empty())
66            .cloned()
67            .collect()
68    }
69
70    pub fn get_query_params(&self) -> Vec<(String, String)> {
71        self.query_params
72            .iter()
73            .filter(|(_, v)| !v.is_empty())
74            .cloned()
75            .collect()
76    }
77
78    fn middle_areas(&self, mid: Rect) -> MiddleAreas {
79        let has_path = !self.path_params.is_empty();
80
81        if !has_path {
82            if self.endpoint.has_body {
83                let q = Layout::default()
84                    .direction(Direction::Horizontal)
85                    .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
86                    .split(mid);
87                return MiddleAreas {
88                    path: None,
89                    query: q[0],
90                    body: Some(q[1]),
91                };
92            }
93            return MiddleAreas {
94                path: None,
95                query: mid,
96                body: None,
97            };
98        }
99
100        let path_lines = self.path_params.len() as u16;
101        let path_h = (path_lines + 2).clamp(3, mid.height.saturating_sub(4));
102        let chunks = Layout::default()
103            .direction(Direction::Vertical)
104            .constraints([Constraint::Length(path_h), Constraint::Min(3)])
105            .split(mid);
106        let path_r = chunks[0];
107        let bottom = chunks[1];
108
109        if self.endpoint.has_body {
110            let q = Layout::default()
111                .direction(Direction::Horizontal)
112                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
113                .split(bottom);
114            MiddleAreas {
115                path: Some(path_r),
116                query: q[0],
117                body: Some(q[1]),
118            }
119        } else {
120            MiddleAreas {
121                path: Some(path_r),
122                query: bottom,
123                body: None,
124            }
125        }
126    }
127
128    pub fn next_field(&mut self) {
129        match self.focused_field {
130            FocusedField::PathParams => {
131                if self.path_param_input_idx + 1 < self.path_params.len() {
132                    self.path_param_input_idx += 1;
133                } else if !self.query_params.is_empty() {
134                    self.focused_field = FocusedField::QueryParams;
135                    self.param_input_idx = 0;
136                } else if self.endpoint.has_body {
137                    self.focused_field = FocusedField::Body;
138                } else {
139                    self.path_param_input_idx = 0;
140                }
141            }
142            FocusedField::QueryParams => {
143                if self.param_input_idx + 1 < self.query_params.len() {
144                    self.param_input_idx += 1;
145                } else if self.endpoint.has_body {
146                    self.focused_field = FocusedField::Body;
147                } else if !self.path_params.is_empty() {
148                    self.focused_field = FocusedField::PathParams;
149                    self.path_param_input_idx = 0;
150                } else {
151                    self.param_input_idx = 0;
152                }
153            }
154            FocusedField::Body => {
155                if !self.path_params.is_empty() {
156                    self.focused_field = FocusedField::PathParams;
157                    self.path_param_input_idx = 0;
158                } else {
159                    self.focused_field = FocusedField::QueryParams;
160                    self.param_input_idx = 0;
161                }
162            }
163        }
164    }
165
166    pub fn previous_field(&mut self) {
167        match self.focused_field {
168            FocusedField::PathParams => {
169                if self.path_param_input_idx > 0 {
170                    self.path_param_input_idx -= 1;
171                } else if self.endpoint.has_body {
172                    self.focused_field = FocusedField::Body;
173                } else if !self.query_params.is_empty() {
174                    self.focused_field = FocusedField::QueryParams;
175                    self.param_input_idx = self.query_params.len().saturating_sub(1);
176                } else {
177                    self.path_param_input_idx = self.path_params.len().saturating_sub(1);
178                }
179            }
180            FocusedField::QueryParams => {
181                if self.param_input_idx > 0 {
182                    self.param_input_idx -= 1;
183                } else if !self.path_params.is_empty() {
184                    self.focused_field = FocusedField::PathParams;
185                    self.path_param_input_idx = self.path_params.len().saturating_sub(1);
186                } else if self.endpoint.has_body {
187                    self.focused_field = FocusedField::Body;
188                } else {
189                    self.param_input_idx = self.query_params.len().saturating_sub(1);
190                }
191            }
192            FocusedField::Body => {
193                if !self.query_params.is_empty() {
194                    self.focused_field = FocusedField::QueryParams;
195                    self.param_input_idx = self.query_params.len().saturating_sub(1);
196                } else if !self.path_params.is_empty() {
197                    self.focused_field = FocusedField::PathParams;
198                    self.path_param_input_idx = self.path_params.len().saturating_sub(1);
199                }
200            }
201        }
202    }
203
204    pub fn add_char_to_focused(&mut self, c: char) {
205        match self.focused_field {
206            FocusedField::PathParams => {
207                if let Some((_, ref mut v)) = self.path_params.get_mut(self.path_param_input_idx) {
208                    v.push(c);
209                }
210            }
211            FocusedField::QueryParams => {
212                if let Some((_, ref mut v)) = self.query_params.get_mut(self.param_input_idx) {
213                    v.push(c);
214                }
215            }
216            FocusedField::Body => {
217                self.body_text.push(c);
218            }
219        }
220    }
221
222    pub fn delete_char_from_focused(&mut self) {
223        match self.focused_field {
224            FocusedField::PathParams => {
225                if let Some((_, ref mut v)) = self.path_params.get_mut(self.path_param_input_idx) {
226                    v.pop();
227                }
228            }
229            FocusedField::QueryParams => {
230                if let Some((_, ref mut v)) = self.query_params.get_mut(self.param_input_idx) {
231                    v.pop();
232                }
233            }
234            FocusedField::Body => {
235                self.body_text.pop();
236            }
237        }
238    }
239
240    pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
241        let chunks = Layout::default()
242            .constraints([
243                Constraint::Length(3),
244                Constraint::Min(5),
245                Constraint::Length(3),
246            ])
247            .split(area);
248
249        let mid = self.middle_areas(chunks[1]);
250
251        match self.focused_field {
252            FocusedField::PathParams => mid.path.and_then(|pa| self.cursor_in_path_params(pa)),
253            FocusedField::QueryParams => self.cursor_in_query_params(mid.query),
254            FocusedField::Body => mid.body.and_then(|ba| self.cursor_in_body(ba)),
255        }
256    }
257
258    fn cursor_in_path_params(&self, area: Rect) -> Option<(u16, u16)> {
259        if area.width < 3 || area.height < 3 {
260            return None;
261        }
262
263        let idx = self
264            .path_param_input_idx
265            .min(self.path_params.len().saturating_sub(1));
266        let y = area.y + 1 + idx as u16;
267        if y >= area.y + area.height - 1 {
268            return None;
269        }
270
271        let (name, value) = self.path_params.get(idx)?;
272        let param = self.endpoint.path_params.get(idx);
273        let required = param.map(|p| p.required).unwrap_or(false);
274        let param_type = param
275            .map(|p| p.param_type.clone())
276            .unwrap_or_else(|| "string".to_string());
277        let marker = if required { "*" } else { " " };
278
279        let line = format!("{marker} {name} ({param_type}) = {value}");
280        let max_x = area.x + area.width - 2;
281        let mut x = area.x + 1 + line.chars().count() as u16;
282        if x > max_x {
283            x = max_x;
284        }
285
286        Some((x, y))
287    }
288
289    fn cursor_in_query_params(&self, area: Rect) -> Option<(u16, u16)> {
290        if area.width < 3 || area.height < 3 {
291            return None;
292        }
293
294        let idx = self
295            .param_input_idx
296            .min(self.query_params.len().saturating_sub(1));
297        let y = area.y + 1 + idx as u16;
298        if y >= area.y + area.height - 1 {
299            return None;
300        }
301
302        let (name, value) = self.query_params.get(idx)?;
303        let param = self.endpoint.query_params.get(idx);
304        let required = param.map(|p| p.required).unwrap_or(false);
305        let param_type = param
306            .map(|p| p.param_type.clone())
307            .unwrap_or_else(|| "string".to_string());
308        let marker = if required { "*" } else { " " };
309
310        let line = format!("{marker} {name} ({param_type}) = {value}");
311        let max_x = area.x + area.width - 2;
312        let mut x = area.x + 1 + line.chars().count() as u16;
313        if x > max_x {
314            x = max_x;
315        }
316
317        Some((x, y))
318    }
319
320    fn cursor_in_body(&self, area: Rect) -> Option<(u16, u16)> {
321        if area.width < 3 || area.height < 3 {
322            return None;
323        }
324
325        let lines: Vec<&str> = self.body_text.split('\n').collect();
326        let last_line = lines.last().copied().unwrap_or("");
327        let line_idx = lines.len().saturating_sub(1);
328
329        let max_visible_lines = (area.height - 2) as usize;
330        let visible_line_idx = line_idx.min(max_visible_lines.saturating_sub(1));
331
332        let y = area.y + 1 + visible_line_idx as u16;
333        let max_x = area.x + area.width - 2;
334        let mut x = area.x + 1 + last_line.chars().count() as u16;
335        if x > max_x {
336            x = max_x;
337        }
338
339        Some((x, y))
340    }
341
342    pub fn render(&self, f: &mut Frame, area: ratatui::layout::Rect) {
343        let chunks = Layout::default()
344            .constraints([
345                Constraint::Length(3),
346                Constraint::Min(5),
347                Constraint::Length(3),
348            ])
349            .split(area);
350
351        let info_text = format!("{} {}", self.endpoint.method, self.endpoint.path);
352        let info = Paragraph::new(info_text)
353            .block(Block::default().title("Endpoint").borders(Borders::ALL));
354        f.render_widget(info, chunks[0]);
355
356        let mid = self.middle_areas(chunks[1]);
357        if let Some(pa) = mid.path {
358            self.render_path_params(f, pa);
359        }
360        self.render_query_params(f, mid.query);
361        if let Some(ba) = mid.body {
362            self.render_body(f, ba);
363        }
364
365        let help_text = "Tab: Next field | Backspace: Delete | Enter: Execute | Esc: Back";
366        let help = Paragraph::new(help_text).block(Block::default().borders(Borders::ALL));
367        f.render_widget(help, chunks[2]);
368    }
369
370    fn render_path_params(&self, f: &mut Frame, area: Rect) {
371        let items: Vec<String> = self
372            .path_params
373            .iter()
374            .enumerate()
375            .map(|(idx, (name, value))| {
376                let param = self.endpoint.path_params.get(idx);
377                let required = param.map(|p| p.required).unwrap_or(false);
378                let param_type = param
379                    .map(|p| p.param_type.clone())
380                    .unwrap_or_else(|| "string".to_string());
381                let marker = if required { "*" } else { " " };
382                format!("{} {} ({}) = {}", marker, name, param_type, value)
383            })
384            .collect();
385
386        let text = items.join("\n");
387        let mut paragraph = Paragraph::new(text).block(
388            Block::default()
389                .title("Path parameters")
390                .borders(Borders::ALL),
391        );
392
393        if self.focused_field == FocusedField::PathParams {
394            paragraph =
395                paragraph.style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow));
396        }
397
398        f.render_widget(paragraph, area);
399    }
400
401    fn render_query_params(&self, f: &mut Frame, area: Rect) {
402        let items: Vec<String> = self
403            .query_params
404            .iter()
405            .enumerate()
406            .map(|(idx, (name, value))| {
407                let param = self.endpoint.query_params.get(idx);
408                let required = param.map(|p| p.required).unwrap_or(false);
409                let param_type = param
410                    .map(|p| p.param_type.clone())
411                    .unwrap_or_else(|| "string".to_string());
412                let marker = if required { "*" } else { " " };
413                format!("{} {} ({}) = {}", marker, name, param_type, value)
414            })
415            .collect();
416
417        let text = items.join("\n");
418        let mut paragraph = Paragraph::new(text).block(
419            Block::default()
420                .title("Query Parameters")
421                .borders(Borders::ALL),
422        );
423
424        if self.focused_field == FocusedField::QueryParams {
425            paragraph =
426                paragraph.style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow));
427        }
428
429        f.render_widget(paragraph, area);
430    }
431
432    fn render_body(&self, f: &mut Frame, area: Rect) {
433        let mut paragraph = Paragraph::new(self.body_text.as_str())
434            .block(
435                Block::default()
436                    .title("Request Body (JSON)")
437                    .borders(Borders::ALL),
438            )
439            .wrap(ratatui::widgets::Wrap { trim: true });
440
441        if self.focused_field == FocusedField::Body {
442            paragraph =
443                paragraph.style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow));
444        }
445
446        f.render_widget(paragraph, area);
447    }
448}