src/ui.rs 27.4 K raw
1
//! Rendering: sidebar / URL bar / editor tabs / response / status / popups.
2
3
use ratatui::Frame;
4
use ratatui::layout::{Constraint, Layout, Rect};
5
use ratatui::style::{Color, Modifier, Style};
6
use ratatui::text::{Line, Span};
7
use ratatui::widgets::{
8
    Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState,
9
    Tabs, Wrap,
10
};
11
12
use crate::app::{App, EditTarget, EditorTab, Focus, Mode, Popup, SidebarRow, TableId};
13
use crate::highlight;
14
use crate::http::DYNAMIC_VARS;
15
use crate::model::Method;
16
17
const SIDEBAR_WIDTH: u16 = 38;
18
19
pub fn draw(f: &mut Frame, app: &mut App) {
20
    let area = f.area();
21
    let [main_area, status_area] =
22
        Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(area);
23
    if app.zoom {
24
        // Only the focused pane is drawn, filling everything above the status
25
        // bar. The URL bar belongs to the editor pane (it renders the selected
26
        // request and takes the editor's focus colour), so it comes along.
27
        match app.focus {
28
            Focus::Sidebar => draw_sidebar(f, app, main_area),
29
            Focus::Editor => {
30
                let [url_area, editor_area] =
31
                    Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).areas(main_area);
32
                draw_url_bar(f, app, url_area);
33
                draw_editor(f, app, editor_area);
34
            }
35
            Focus::Response => draw_response(f, app, main_area),
36
        }
37
    } else {
38
        let [side_area, right_area] =
39
            Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(40)])
40
                .areas(main_area);
41
        let [url_area, editor_area, response_area] = Layout::vertical([
42
            Constraint::Length(3),
43
            Constraint::Percentage(45),
44
            Constraint::Min(5),
45
        ])
46
        .areas(right_area);
47
48
        draw_sidebar(f, app, side_area);
49
        draw_url_bar(f, app, url_area);
50
        draw_editor(f, app, editor_area);
51
        draw_response(f, app, response_area);
52
    }
53
    draw_status(f, app, status_area);
54
55
    match app.popup {
56
        Popup::Help => draw_help(f, app, area),
57
        Popup::Env => draw_env(f, app, area),
58
        Popup::Auth => draw_auth(f, app, area),
59
        Popup::None => {}
60
    }
61
}
62
63
// ----- shared styles -----
64
65
fn focused(focus: bool) -> Style {
66
    if focus {
67
        Style::default().fg(Color::Cyan)
68
    } else {
69
        Style::default().fg(Color::DarkGray)
70
    }
71
}
72
73
fn method_color(m: Method) -> Color {
74
    match m {
75
        Method::Get => Color::Green,
76
        Method::Post => Color::Yellow,
77
        Method::Put => Color::Blue,
78
        Method::Patch => Color::Magenta,
79
        Method::Delete => Color::Red,
80
        Method::Head => Color::Cyan,
81
        Method::Options => Color::Gray,
82
    }
83
}
84
85
fn checkbox(enabled: bool) -> &'static str {
86
    if enabled { "[x]" } else { "[ ]" }
87
}
88
89
// ----- sidebar -----
90
91
fn draw_sidebar(f: &mut Frame, app: &mut App, area: Rect) {
92
    let title = if app.filter.is_empty() {
93
        format!(" {} ", app.collection.name)
94
    } else {
95
        format!(" {} — /{} ", app.collection.name, app.filter)
96
    };
97
    let block = Block::default()
98
        .title(title)
99
        .borders(Borders::ALL)
100
        .border_style(focused(app.focus == Focus::Sidebar));
101
102
    let mut items: Vec<ListItem> = Vec::new();
103
    for row in &app.sidebar_rows {
104
        match row {
105
            SidebarRow::Group(tag) => {
106
                let marker = if app.collapsed.contains(tag) {
107
                    "▸"
108
                } else {
109
                    "▾"
110
                };
111
                items.push(ListItem::new(Line::from(vec![Span::styled(
112
                    format!("{marker} {tag}"),
113
                    Style::default().add_modifier(Modifier::BOLD),
114
                )])));
115
            }
116
            SidebarRow::Request(i) => {
117
                let req = &app.collection.requests[*i];
118
                items.push(ListItem::new(Line::from(vec![
119
                    Span::styled(
120
                        format!("  {:<7}", req.method.to_string()),
121
                        Style::default().fg(method_color(req.method)),
122
                    ),
123
                    Span::raw(req.label(app.collection.label_mode).to_string()),
124
                ])));
125
            }
126
        }
127
    }
128
129
    let list = List::new(items)
130
        .block(block)
131
        .highlight_style(
132
            Style::default()
133
                .bg(Color::DarkGray)
134
                .add_modifier(Modifier::BOLD),
135
        )
136
        .highlight_symbol(">");
137
    // Keep the selection centered while scrolling; pin to the ends near top/bottom.
138
    let viewport = area.height.saturating_sub(2) as usize; // minus borders
139
    let offset = centered_offset(app.sidebar_sel, app.sidebar_rows.len(), viewport);
140
    let mut state = ListState::default()
141
        .with_selected(Some(app.sidebar_sel))
142
        .with_offset(offset);
143
    f.render_stateful_widget(list, area, &mut state);
144
}
145
146
/// Scroll offset that holds `sel` at the vertical middle of a `viewport`-tall
147
/// list, clamped so the first and last items never scroll past the edges.
148
fn centered_offset(sel: usize, len: usize, viewport: usize) -> usize {
149
    if viewport == 0 || len <= viewport {
150
        return 0;
151
    }
152
    let max_offset = len - viewport;
153
    sel.saturating_sub(viewport / 2).min(max_offset)
154
}
155
156
// ----- URL bar -----
157
158
fn draw_url_bar(f: &mut Frame, app: &App, area: Rect) {
159
    let (title, line) = match app.selected_request() {
160
        Some(req) => {
161
            let url = format!(
162
                "{}{}",
163
                app.collection.base_url().unwrap_or("<no server — press E>"),
164
                req.path
165
            );
166
            (
167
                format!(" {} — p: edit url ", req.name),
168
                Line::from(vec![
169
                    Span::styled(
170
                        format!(" {:<7}", req.method.to_string()),
171
                        Style::default()
172
                            .fg(method_color(req.method))
173
                            .add_modifier(Modifier::BOLD),
174
                    ),
175
                    Span::raw(url),
176
                ]),
177
            )
178
        }
179
        None => (
180
            " cielago ".to_string(),
181
            Line::from("No request selected — pick one from the sidebar"),
182
        ),
183
    };
184
    let block = Block::default()
185
        .title(title)
186
        .borders(Borders::ALL)
187
        .border_style(focused(app.focus == Focus::Editor));
188
    f.render_widget(Paragraph::new(line).block(block), area);
189
}
190
191
// ----- editor -----
192
193
fn draw_editor(f: &mut Frame, app: &mut App, area: Rect) {
194
    let outer = Block::default()
195
        .borders(Borders::ALL)
196
        .border_style(focused(app.focus == Focus::Editor));
197
    let inner = outer.inner(area);
198
    f.render_widget(outer, area);
199
200
    let [tabs_area, content_area] =
201
        Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(inner);
202
203
    let tabs = Tabs::new(EditorTab::ALL.iter().map(|t| t.title()).collect::<Vec<_>>())
204
        .select(app.tab.index())
205
        .highlight_style(
206
            Style::default()
207
                .fg(Color::Cyan)
208
                .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
209
        )
210
        .divider("│");
211
    f.render_widget(tabs, tabs_area);
212
213
    match app.tab {
214
        EditorTab::Body => draw_body(f, app, content_area),
215
        EditorTab::Docs => draw_docs(f, app, content_area),
216
        tab => {
217
            if let Some(table) = tab.table() {
218
                draw_table(f, app, content_area, table)
219
            }
220
        }
221
    }
222
}
223
224
/// The body is syntax-highlighted and read-only; edits go through `$EDITOR`
225
/// (`e`). `body_scroll` is a plain offset, clamped here against the content.
226
fn draw_body(f: &mut Frame, app: &mut App, area: Rect) {
227
    let block = Block::default()
228
        .title(" Body — e: $EDITOR · j/k: scroll ")
229
        .borders(Borders::NONE);
230
    let inner = block.inner(area);
231
    f.render_widget(block, area);
232
233
    let lines = highlight::highlight(&app.body_text, true);
234
    let max = lines.len().saturating_sub(1);
235
    app.body_scroll = app.body_scroll.min(max);
236
    f.render_widget(
237
        Paragraph::new(lines).scroll((app.body_scroll as u16, 0)),
238
        inner,
239
    );
240
}
241
242
/// Read-only view of what the spec says about this request: the operation
243
/// description, then every parameter and body field with its type, accepted
244
/// values and default.
245
fn draw_docs(f: &mut Frame, app: &mut App, area: Rect) {
246
    let block = Block::default()
247
        .title(" Docs — j/k: scroll · read-only ")
248
        .borders(Borders::NONE);
249
    let inner = block.inner(area);
250
    f.render_widget(block, area);
251
252
    let mut lines: Vec<Line> = Vec::new();
253
    match app.selected_request() {
254
        None => lines.push(Line::raw("No request selected.")),
255
        Some(req) => {
256
            lines.push(Line::from(vec![
257
                Span::styled(
258
                    format!("{} {}", req.method, req.path),
259
                    Style::default()
260
                        .fg(method_color(req.method))
261
                        .add_modifier(Modifier::BOLD),
262
                ),
263
                Span::raw("  "),
264
                Span::styled(
265
                    req.summary.clone().unwrap_or_else(|| req.name.clone()),
266
                    Style::default().fg(Color::Gray),
267
                ),
268
            ]));
269
            if let Some(desc) = &req.description {
270
                lines.push(Line::raw(""));
271
                lines.extend(
272
                    desc.lines()
273
                        .map(|l| Line::styled(l.to_string(), Style::default().fg(Color::Gray))),
274
                );
275
            }
276
277
            if req.docs.is_empty() {
278
                lines.push(Line::raw(""));
279
                lines.push(Line::styled(
280
                    "No spec docs for this request. Hand-made requests have none;",
281
                    Style::default().fg(Color::DarkGray),
282
                ));
283
                lines.push(Line::styled(
284
                    "for imported ones, re-import the spec to fill this in.",
285
                    Style::default().fg(Color::DarkGray),
286
                ));
287
            }
288
            for (location, heading) in [
289
                ("path", "Path params"),
290
                ("query", "Query params"),
291
                ("header", "Headers"),
292
                ("body", "Body"),
293
                ("response", "Response"),
294
            ] {
295
                let fields = req.docs.iter().filter(|d| d.location == location);
296
                let mut first = true;
297
                for d in fields {
298
                    if first {
299
                        lines.push(Line::raw(""));
300
                        lines.push(Line::styled(
301
                            heading.to_string(),
302
                            Style::default().add_modifier(Modifier::BOLD),
303
                        ));
304
                        first = false;
305
                    }
306
                    let mut head = vec![
307
                        Span::styled(format!("  {}", d.name), Style::default().fg(Color::Cyan)),
308
                        // Required fields are starred, as in most API docs.
309
                        Span::styled(
310
                            if d.required { "*" } else { "" },
311
                            Style::default().fg(Color::Red),
312
                        ),
313
                        Span::raw("  "),
314
                        Span::styled(d.ty.clone(), Style::default().fg(Color::Yellow)),
315
                    ];
316
                    if let Some(default) = &d.default {
317
                        head.push(Span::styled(
318
                            format!("  = {default}"),
319
                            Style::default().fg(Color::DarkGray),
320
                        ));
321
                    }
322
                    lines.push(Line::from(head));
323
                    if !d.options.is_empty() {
324
                        lines.push(Line::from(vec![
325
                            Span::styled("    one of: ", Style::default().fg(Color::DarkGray)),
326
                            Span::styled(
327
                                d.options.join(" | "),
328
                                Style::default().fg(Color::Magenta),
329
                            ),
330
                        ]));
331
                    }
332
                    if let Some(desc) = &d.description {
333
                        lines.extend(desc.lines().map(|l| {
334
                            Line::styled(format!("    {l}"), Style::default().fg(Color::Gray))
335
                        }));
336
                    }
337
                }
338
            }
339
        }
340
    }
341
342
    // Wrapping means this is a lower bound on the rendered height, so the last
343
    // line always stays reachable.
344
    let max_scroll = lines.len().saturating_sub(inner.height as usize);
345
    app.docs_scroll = app.docs_scroll.min(max_scroll);
346
    f.render_widget(
347
        Paragraph::new(lines)
348
            .wrap(Wrap { trim: false })
349
            .scroll((app.docs_scroll as u16, 0)),
350
        inner,
351
    );
352
}
353
354
fn draw_table(f: &mut Frame, app: &App, area: Rect, table: TableId) {
355
    let rows_data: Vec<(bool, String, String, String)> = match table {
356
        TableId::Params => {
357
            let mut v: Vec<(bool, String, String, String)> = Vec::new();
358
            if let Some(req) = app.selected_request() {
359
                v.extend(
360
                    req.path_params
361
                        .iter()
362
                        .map(|r| (r.enabled, "path".into(), r.key.clone(), r.value.clone())),
363
                );
364
                v.extend(
365
                    req.query
366
                        .iter()
367
                        .map(|r| (r.enabled, "query".into(), r.key.clone(), r.value.clone())),
368
                );
369
            }
370
            v
371
        }
372
        TableId::Headers => app
373
            .selected_request()
374
            .map(|req| {
375
                req.headers
376
                    .iter()
377
                    .map(|r| (r.enabled, String::new(), r.key.clone(), r.value.clone()))
378
                    .collect()
379
            })
380
            .unwrap_or_default(),
381
        TableId::Vars => app
382
            .collection
383
            .variables
384
            .iter()
385
            .map(|r| (r.enabled, String::new(), r.key.clone(), r.value.clone()))
386
            .collect(),
387
    };
388
389
    let rows: Vec<Row> = rows_data
390
        .iter()
391
        .map(|(enabled, loc, key, value)| {
392
            let style = if *enabled {
393
                Style::default()
394
            } else {
395
                Style::default().fg(Color::DarkGray)
396
            };
397
            let mut cells = vec![Cell::from(checkbox(*enabled))];
398
            if table == TableId::Params {
399
                cells.push(Cell::from(loc.clone()));
400
            }
401
            cells.push(Cell::from(key.clone()));
402
            cells.push(Cell::from(value.clone()));
403
            Row::new(cells).style(style)
404
        })
405
        .collect();
406
407
    let widths: Vec<Constraint> = if table == TableId::Params {
408
        vec![
409
            Constraint::Length(4),
410
            Constraint::Length(6),
411
            Constraint::Percentage(30),
412
            Constraint::Min(10),
413
        ]
414
    } else {
415
        vec![
416
            Constraint::Length(4),
417
            Constraint::Percentage(30),
418
            Constraint::Min(10),
419
        ]
420
    };
421
422
    let hint = match table {
423
        TableId::Vars => {
424
            " Variables — {{name}} usable anywhere · space: toggle · a: add · i: edit · d: del "
425
        }
426
        _ => " space: toggle · a: add · i: edit · d: del · Enter: send ",
427
    };
428
429
    let t = Table::new(rows, widths)
430
        .block(Block::default().title(hint).borders(Borders::NONE))
431
        .row_highlight_style(
432
            Style::default()
433
                .bg(Color::DarkGray)
434
                .add_modifier(Modifier::BOLD),
435
        )
436
        .highlight_symbol(">");
437
    let mut state = TableState::default().with_selected(if rows_data.is_empty() {
438
        None
439
    } else {
440
        Some(app.table_row)
441
    });
442
    f.render_stateful_widget(t, area, &mut state);
443
}
444
445
// ----- response -----
446
447
fn draw_response(f: &mut Frame, app: &mut App, area: Rect) {
448
    let title = match (&app.response, app.sending) {
449
        (_, true) => " Response — sending… ".to_string(),
450
        (Some(resp), false) => format!(" Response — {} ", resp.status_line()),
451
        (None, false) => " Response ".to_string(),
452
    };
453
    let block = Block::default()
454
        .title(title)
455
        .borders(Borders::ALL)
456
        .border_style(focused(app.focus == Focus::Response));
457
458
    let body = app
459
        .response
460
        .as_ref()
461
        .map(|r| r.body.as_str())
462
        .unwrap_or("No response yet — press Enter on the editor to send.");
463
464
    // Clamp scroll to content length.
465
    let max_scroll = body.lines().count().saturating_sub(1);
466
    if app.response_scroll > max_scroll {
467
        app.response_scroll = max_scroll;
468
    }
469
470
    // `{{…}}` in a response is literal server output, not template syntax.
471
    let p = Paragraph::new(highlight::highlight(body, false))
472
        .block(block)
473
        .wrap(Wrap { trim: false })
474
        .scroll((app.response_scroll as u16, 0));
475
    f.render_widget(p, area);
476
}
477
478
// ----- status bar -----
479
480
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
481
    match app.mode {
482
        Mode::Command => {
483
            f.render_widget(Paragraph::new(format!(":{}", app.command)), area);
484
            f.set_cursor_position((area.x + 1 + app.command.len() as u16, area.y));
485
        }
486
        Mode::Search => {
487
            f.render_widget(Paragraph::new(format!("/{}", app.search.buf)), area);
488
            let cursor_chars = app.search.buf[..app.search.cursor].chars().count() as u16;
489
            f.set_cursor_position((area.x + 1 + cursor_chars, area.y));
490
        }
491
        Mode::Insert if app.editing.is_some() => {
492
            let label = match app.editing.unwrap() {
493
                EditTarget::Cell { col, .. } => match col {
494
                    crate::app::CellCol::Key => "key",
495
                    crate::app::CellCol::Value => "value",
496
                },
497
                EditTarget::Rename => "rename",
498
                EditTarget::NewRequest => "new request",
499
                EditTarget::Url => "url (verb path)",
500
                EditTarget::EnvNew => "server url",
501
                EditTarget::AuthField(_) => "auth",
502
            };
503
            let prompt = format!("{label}> ");
504
            f.render_widget(Paragraph::new(format!("{prompt}{}", app.input.buf)), area);
505
            let cursor_chars = app.input.buf[..app.input.cursor].chars().count() as u16;
506
            f.set_cursor_position((area.x + prompt.len() as u16 + cursor_chars, area.y));
507
        }
508
        _ => {
509
            let mode_badge = match app.mode {
510
                Mode::Normal => Span::styled(
511
                    " NORMAL ",
512
                    Style::default().bg(Color::Green).fg(Color::Black),
513
                ),
514
                Mode::Insert => Span::styled(
515
                    " INSERT ",
516
                    Style::default().bg(Color::Yellow).fg(Color::Black),
517
                ),
518
                Mode::Command | Mode::Search => Span::raw(""),
519
            };
520
            let dirty = if app.dirty { "*" } else { "" };
521
            let server = app.collection.base_url().unwrap_or("no server");
522
            let line = Line::from(vec![
523
                mode_badge,
524
                Span::raw(format!(
525
                    " {}{} | {} | {} ",
526
                    app.collection.name, dirty, server, app.status
527
                )),
528
            ]);
529
            f.render_widget(Paragraph::new(line), area);
530
        }
531
    }
532
}
533
534
// ----- popups -----
535
536
fn centered(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
537
    let [_, v, _] = Layout::vertical([
538
        Constraint::Percentage((100 - pct_y) / 2),
539
        Constraint::Percentage(pct_y),
540
        Constraint::Percentage((100 - pct_y) / 2),
541
    ])
542
    .areas(area);
543
    let [_, h, _] = Layout::horizontal([
544
        Constraint::Percentage((100 - pct_x) / 2),
545
        Constraint::Percentage(pct_x),
546
        Constraint::Percentage((100 - pct_x) / 2),
547
    ])
548
    .areas(v);
549
    h
550
}
551
552
fn draw_help(f: &mut Frame, app: &mut App, area: Rect) {
553
    let popup = centered(area, 64, 80);
554
    f.render_widget(Clear, popup);
555
    let mut lines = vec![
556
        Line::styled("Global", Style::default().add_modifier(Modifier::BOLD)),
557
        Line::raw("  1/2/3, Tab   focus sidebar / editor / response"),
558
        Line::raw("  z            maximize the focused pane (z again to restore)"),
559
        Line::raw("  ] or L       next tab (Params/Headers/Body/Docs/Variables)"),
560
        Line::raw("  [ or H       previous editor tab"),
561
        Line::raw("  /            search / filter requests"),
562
        Line::raw("  E            servers / base URLs"),
563
        Line::raw("  A            auth config (bearer / API key / OAuth2)"),
564
        Line::raw("  :            command line (:w save, :q quit, :q! force, :wq)"),
565
        Line::raw("  q            quit (warns when unsaved)"),
566
        Line::raw(""),
567
        Line::styled("Sidebar", Style::default().add_modifier(Modifier::BOLD)),
568
        Line::raw("  j/k, g/G     navigate"),
569
        Line::raw("  Enter/h/l    open request · collapse/expand group"),
570
        Line::raw("  n/r/d/y      new / rename / delete / duplicate request"),
571
        Line::raw("  /            filter (Enter keeps it, Esc clears)"),
572
        Line::raw("  t            cycle labels: name → summary → path"),
573
        Line::raw(""),
574
        Line::styled(
575
            "Editor (tables)",
576
            Style::default().add_modifier(Modifier::BOLD),
577
        ),
578
        Line::raw("  Enter        send request"),
579
        Line::raw("  i            edit value of selected row"),
580
        Line::raw("  a            add row (key then value)"),
581
        Line::raw("  space        enable/disable row"),
582
        Line::raw("  d            delete row · m cycle method · r rename"),
583
        Line::raw("  p            edit URL / path (paste a full URL to set"),
584
        Line::raw("               the server; ?query fills the Params tab;"),
585
        Line::raw("               a leading verb sets the method, e.g."),
586
        Line::raw("               `post /pets`)"),
587
        Line::raw(""),
588
        Line::styled("Body tab", Style::default().add_modifier(Modifier::BOLD)),
589
        Line::raw("  j/k, d/u     scroll · g/G top/bottom"),
590
        Line::raw("  e            edit in $EDITOR"),
591
        Line::raw(""),
592
        Line::styled("Docs tab", Style::default().add_modifier(Modifier::BOLD)),
593
        Line::raw("  types, enums and descriptions from the spec (* = required)"),
594
        Line::raw("  j/k, d/u     scroll · g/G top/bottom"),
595
        Line::raw(""),
596
        Line::styled("Response", Style::default().add_modifier(Modifier::BOLD)),
597
        Line::raw("  j/k, d/u     scroll · g/G top/bottom"),
598
        Line::raw("  e            open in $EDITOR (view only)"),
599
        Line::raw(""),
600
        Line::styled("Variables", Style::default().add_modifier(Modifier::BOLD)),
601
        Line::raw("  {{name}} in paths, params, headers and bodies —"),
602
        Line::raw("  substituted at send time. Dynamic ones, computed"),
603
        Line::raw("  per send ({{$name}} to bypass a same-named variable):"),
604
    ];
605
    lines.extend(DYNAMIC_VARS.iter().map(|(name, help)| {
606
        Line::from(vec![
607
            Span::styled(format!("  {name:<15}"), Style::default().fg(Color::Magenta)),
608
            Span::raw(*help),
609
        ])
610
    }));
611
    lines.extend([
612
        Line::raw(""),
613
        Line::styled("Commands", Style::default().add_modifier(Modifier::BOLD)),
614
        Line::raw("  :new <name>                      create a collection"),
615
        Line::raw("  :open <name>                     switch collection"),
616
        Line::raw("  :label name|summary|path         sidebar label source"),
617
        Line::raw("  :groups collapsed|expanded       group state on open"),
618
        Line::raw("  :rename-all summary|operation|path|method-path"),
619
    ]);
620
621
    // The list outgrows short terminals, so the popup scrolls with j/k.
622
    let viewport = popup.height.saturating_sub(2) as usize;
623
    let max_scroll = lines.len().saturating_sub(viewport);
624
    app.help_scroll = app.help_scroll.min(max_scroll);
625
    let more = if app.help_scroll < max_scroll {
626
        " Help — j/k scroll · Esc to close "
627
    } else {
628
        " Help — Esc to close "
629
    };
630
    let block = Block::default()
631
        .title(more)
632
        .borders(Borders::ALL)
633
        .border_style(Style::default().fg(Color::Cyan));
634
    f.render_widget(
635
        Paragraph::new(lines)
636
            .block(block)
637
            .scroll((app.help_scroll as u16, 0)),
638
        popup,
639
    );
640
}
641
642
fn draw_env(f: &mut Frame, app: &App, area: Rect) {
643
    let popup = centered(area, 60, 50);
644
    f.render_widget(Clear, popup);
645
    let items: Vec<ListItem> = app
646
        .collection
647
        .servers
648
        .iter()
649
        .enumerate()
650
        .map(|(i, s)| {
651
            let marker = if i == app.collection.active_server {
652
                "● "
653
            } else {
654
                "  "
655
            };
656
            ListItem::new(format!("{marker}{s}"))
657
        })
658
        .collect();
659
    let block = Block::default()
660
        .title(" Servers — Enter: use · a: add · d: delete · Esc: close ")
661
        .borders(Borders::ALL)
662
        .border_style(Style::default().fg(Color::Cyan));
663
    let list = List::new(items)
664
        .block(block)
665
        .highlight_style(Style::default().bg(Color::DarkGray))
666
        .highlight_symbol(">");
667
    let mut state = ListState::default().with_selected(Some(app.env_sel));
668
    f.render_stateful_widget(list, popup, &mut state);
669
}
670
671
fn draw_auth(f: &mut Frame, app: &App, area: Rect) {
672
    use crate::app::AuthField;
673
    use crate::model::{AuthKind, AuthStyle};
674
675
    let popup = centered(area, 70, 45);
676
    f.render_widget(Clear, popup);
677
    let block = Block::default()
678
        .title(" Auth — j/k: field · i/Enter: edit · space: toggle · Esc: save & close ")
679
        .borders(Borders::ALL)
680
        .border_style(Style::default().fg(Color::Cyan));
681
    let inner = block.inner(popup);
682
    f.render_widget(block, popup);
683
684
    let fields = app.auth_fields();
685
    let mut lines = Vec::new();
686
    for (i, field) in fields.iter().enumerate() {
687
        let value = match field {
688
            AuthField::Kind => toggle_row(app.auth_form.kind, AuthKind::ALL, AuthKind::title),
689
            AuthField::Style => toggle_row(
690
                app.auth_form.auth_style,
691
                [AuthStyle::Basic, AuthStyle::Post],
692
                |s| match s {
693
                    AuthStyle::Basic => "basic",
694
                    AuthStyle::Post => "post",
695
                },
696
            ),
697
            f if f.is_secret() && !app.auth_field_value(i).is_empty() => "••••••••".to_string(),
698
            _ => app.auth_field_value(i),
699
        };
700
        let style = if i == app.auth_field {
701
            Style::default()
702
                .bg(Color::DarkGray)
703
                .add_modifier(Modifier::BOLD)
704
        } else {
705
            Style::default()
706
        };
707
        let label = app.auth_field_label(*field);
708
        lines.push(
709
            Line::from(vec![
710
                Span::styled(format!(" {label:<28}"), Style::default().fg(Color::Gray)),
711
                Span::raw(value),
712
            ])
713
            .style(style),
714
        );
715
    }
716
717
    // A hint that secret fields understand `$(…)` command substitution.
718
    if fields.iter().any(|f| f.is_secret()) {
719
        lines.push(Line::raw(""));
720
        lines.push(Line::styled(
721
            " secret fields accept $(cmd), e.g. $(op read \"op://vault/item/field\")",
722
            Style::default().fg(Color::DarkGray),
723
        ));
724
    }
725
726
    f.render_widget(Paragraph::new(lines), inner);
727
}
728
729
/// Render a toggle field as its options with the active one bracketed, e.g.
730
/// `[bearer]  apikey  oauth2`.
731
fn toggle_row<T: PartialEq + Copy, const N: usize>(
732
    current: T,
733
    all: [T; N],
734
    label: impl Fn(T) -> &'static str,
735
) -> String {
736
    all.iter()
737
        .map(|opt| {
738
            let name = label(*opt);
739
            if *opt == current {
740
                format!("[{name}]")
741
            } else {
742
                format!(" {name} ")
743
            }
744
        })
745
        .collect::<Vec<_>>()
746
        .join(" ")
747
}