src/ui.rs 27.3 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
            ] {
294
                let fields = req.docs.iter().filter(|d| d.location == location);
295
                let mut first = true;
296
                for d in fields {
297
                    if first {
298
                        lines.push(Line::raw(""));
299
                        lines.push(Line::styled(
300
                            heading.to_string(),
301
                            Style::default().add_modifier(Modifier::BOLD),
302
                        ));
303
                        first = false;
304
                    }
305
                    let mut head = vec![
306
                        Span::styled(format!("  {}", d.name), Style::default().fg(Color::Cyan)),
307
                        // Required fields are starred, as in most API docs.
308
                        Span::styled(
309
                            if d.required { "*" } else { "" },
310
                            Style::default().fg(Color::Red),
311
                        ),
312
                        Span::raw("  "),
313
                        Span::styled(d.ty.clone(), Style::default().fg(Color::Yellow)),
314
                    ];
315
                    if let Some(default) = &d.default {
316
                        head.push(Span::styled(
317
                            format!("  = {default}"),
318
                            Style::default().fg(Color::DarkGray),
319
                        ));
320
                    }
321
                    lines.push(Line::from(head));
322
                    if !d.options.is_empty() {
323
                        lines.push(Line::from(vec![
324
                            Span::styled("    one of: ", Style::default().fg(Color::DarkGray)),
325
                            Span::styled(
326
                                d.options.join(" | "),
327
                                Style::default().fg(Color::Magenta),
328
                            ),
329
                        ]));
330
                    }
331
                    if let Some(desc) = &d.description {
332
                        lines.extend(desc.lines().map(|l| {
333
                            Line::styled(format!("    {l}"), Style::default().fg(Color::Gray))
334
                        }));
335
                    }
336
                }
337
            }
338
        }
339
    }
340
341
    // Wrapping means this is a lower bound on the rendered height, so the last
342
    // line always stays reachable.
343
    let max_scroll = lines.len().saturating_sub(inner.height as usize);
344
    app.docs_scroll = app.docs_scroll.min(max_scroll);
345
    f.render_widget(
346
        Paragraph::new(lines)
347
            .wrap(Wrap { trim: false })
348
            .scroll((app.docs_scroll as u16, 0)),
349
        inner,
350
    );
351
}
352
353
fn draw_table(f: &mut Frame, app: &App, area: Rect, table: TableId) {
354
    let rows_data: Vec<(bool, String, String, String)> = match table {
355
        TableId::Params => {
356
            let mut v: Vec<(bool, String, String, String)> = Vec::new();
357
            if let Some(req) = app.selected_request() {
358
                v.extend(
359
                    req.path_params
360
                        .iter()
361
                        .map(|r| (r.enabled, "path".into(), r.key.clone(), r.value.clone())),
362
                );
363
                v.extend(
364
                    req.query
365
                        .iter()
366
                        .map(|r| (r.enabled, "query".into(), r.key.clone(), r.value.clone())),
367
                );
368
            }
369
            v
370
        }
371
        TableId::Headers => app
372
            .selected_request()
373
            .map(|req| {
374
                req.headers
375
                    .iter()
376
                    .map(|r| (r.enabled, String::new(), r.key.clone(), r.value.clone()))
377
                    .collect()
378
            })
379
            .unwrap_or_default(),
380
        TableId::Vars => app
381
            .collection
382
            .variables
383
            .iter()
384
            .map(|r| (r.enabled, String::new(), r.key.clone(), r.value.clone()))
385
            .collect(),
386
    };
387
388
    let rows: Vec<Row> = rows_data
389
        .iter()
390
        .map(|(enabled, loc, key, value)| {
391
            let style = if *enabled {
392
                Style::default()
393
            } else {
394
                Style::default().fg(Color::DarkGray)
395
            };
396
            let mut cells = vec![Cell::from(checkbox(*enabled))];
397
            if table == TableId::Params {
398
                cells.push(Cell::from(loc.clone()));
399
            }
400
            cells.push(Cell::from(key.clone()));
401
            cells.push(Cell::from(value.clone()));
402
            Row::new(cells).style(style)
403
        })
404
        .collect();
405
406
    let widths: Vec<Constraint> = if table == TableId::Params {
407
        vec![
408
            Constraint::Length(4),
409
            Constraint::Length(6),
410
            Constraint::Percentage(30),
411
            Constraint::Min(10),
412
        ]
413
    } else {
414
        vec![
415
            Constraint::Length(4),
416
            Constraint::Percentage(30),
417
            Constraint::Min(10),
418
        ]
419
    };
420
421
    let hint = match table {
422
        TableId::Vars => {
423
            " Variables — {{name}} usable anywhere · space: toggle · a: add · i: edit · d: del "
424
        }
425
        _ => " space: toggle · a: add · i: edit · d: del · Enter: send ",
426
    };
427
428
    let t = Table::new(rows, widths)
429
        .block(Block::default().title(hint).borders(Borders::NONE))
430
        .row_highlight_style(
431
            Style::default()
432
                .bg(Color::DarkGray)
433
                .add_modifier(Modifier::BOLD),
434
        )
435
        .highlight_symbol(">");
436
    let mut state = TableState::default().with_selected(if rows_data.is_empty() {
437
        None
438
    } else {
439
        Some(app.table_row)
440
    });
441
    f.render_stateful_widget(t, area, &mut state);
442
}
443
444
// ----- response -----
445
446
fn draw_response(f: &mut Frame, app: &mut App, area: Rect) {
447
    let title = match (&app.response, app.sending) {
448
        (_, true) => " Response — sending… ".to_string(),
449
        (Some(resp), false) => format!(" Response — {} ", resp.status_line()),
450
        (None, false) => " Response ".to_string(),
451
    };
452
    let block = Block::default()
453
        .title(title)
454
        .borders(Borders::ALL)
455
        .border_style(focused(app.focus == Focus::Response));
456
457
    let body = app
458
        .response
459
        .as_ref()
460
        .map(|r| r.body.as_str())
461
        .unwrap_or("No response yet — press Enter on the editor to send.");
462
463
    // Clamp scroll to content length.
464
    let max_scroll = body.lines().count().saturating_sub(1);
465
    if app.response_scroll > max_scroll {
466
        app.response_scroll = max_scroll;
467
    }
468
469
    // `{{…}}` in a response is literal server output, not template syntax.
470
    let p = Paragraph::new(highlight::highlight(body, false))
471
        .block(block)
472
        .wrap(Wrap { trim: false })
473
        .scroll((app.response_scroll as u16, 0));
474
    f.render_widget(p, area);
475
}
476
477
// ----- status bar -----
478
479
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
480
    match app.mode {
481
        Mode::Command => {
482
            f.render_widget(Paragraph::new(format!(":{}", app.command)), area);
483
            f.set_cursor_position((area.x + 1 + app.command.len() as u16, area.y));
484
        }
485
        Mode::Search => {
486
            f.render_widget(Paragraph::new(format!("/{}", app.search.buf)), area);
487
            let cursor_chars = app.search.buf[..app.search.cursor].chars().count() as u16;
488
            f.set_cursor_position((area.x + 1 + cursor_chars, area.y));
489
        }
490
        Mode::Insert if app.editing.is_some() => {
491
            let label = match app.editing.unwrap() {
492
                EditTarget::Cell { col, .. } => match col {
493
                    crate::app::CellCol::Key => "key",
494
                    crate::app::CellCol::Value => "value",
495
                },
496
                EditTarget::Rename => "rename",
497
                EditTarget::NewRequest => "new request",
498
                EditTarget::Url => "url (verb path)",
499
                EditTarget::EnvNew => "server url",
500
                EditTarget::AuthField(_) => "auth",
501
            };
502
            let prompt = format!("{label}> ");
503
            f.render_widget(Paragraph::new(format!("{prompt}{}", app.input.buf)), area);
504
            let cursor_chars = app.input.buf[..app.input.cursor].chars().count() as u16;
505
            f.set_cursor_position((area.x + prompt.len() as u16 + cursor_chars, area.y));
506
        }
507
        _ => {
508
            let mode_badge = match app.mode {
509
                Mode::Normal => Span::styled(
510
                    " NORMAL ",
511
                    Style::default().bg(Color::Green).fg(Color::Black),
512
                ),
513
                Mode::Insert => Span::styled(
514
                    " INSERT ",
515
                    Style::default().bg(Color::Yellow).fg(Color::Black),
516
                ),
517
                Mode::Command | Mode::Search => Span::raw(""),
518
            };
519
            let dirty = if app.dirty { "*" } else { "" };
520
            let server = app.collection.base_url().unwrap_or("no server");
521
            let line = Line::from(vec![
522
                mode_badge,
523
                Span::raw(format!(
524
                    " {}{} | {} | {} ",
525
                    app.collection.name, dirty, server, app.status
526
                )),
527
            ]);
528
            f.render_widget(Paragraph::new(line), area);
529
        }
530
    }
531
}
532
533
// ----- popups -----
534
535
fn centered(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
536
    let [_, v, _] = Layout::vertical([
537
        Constraint::Percentage((100 - pct_y) / 2),
538
        Constraint::Percentage(pct_y),
539
        Constraint::Percentage((100 - pct_y) / 2),
540
    ])
541
    .areas(area);
542
    let [_, h, _] = Layout::horizontal([
543
        Constraint::Percentage((100 - pct_x) / 2),
544
        Constraint::Percentage(pct_x),
545
        Constraint::Percentage((100 - pct_x) / 2),
546
    ])
547
    .areas(v);
548
    h
549
}
550
551
fn draw_help(f: &mut Frame, app: &mut App, area: Rect) {
552
    let popup = centered(area, 64, 80);
553
    f.render_widget(Clear, popup);
554
    let mut lines = vec![
555
        Line::styled("Global", Style::default().add_modifier(Modifier::BOLD)),
556
        Line::raw("  1/2/3, Tab   focus sidebar / editor / response"),
557
        Line::raw("  z            maximize the focused pane (z again to restore)"),
558
        Line::raw("  ] or L       next tab (Params/Headers/Body/Docs/Variables)"),
559
        Line::raw("  [ or H       previous editor tab"),
560
        Line::raw("  /            search / filter requests"),
561
        Line::raw("  E            servers / base URLs"),
562
        Line::raw("  A            auth config (bearer / API key / OAuth2)"),
563
        Line::raw("  :            command line (:w save, :q quit, :q! force, :wq)"),
564
        Line::raw("  q            quit (warns when unsaved)"),
565
        Line::raw(""),
566
        Line::styled("Sidebar", Style::default().add_modifier(Modifier::BOLD)),
567
        Line::raw("  j/k, g/G     navigate"),
568
        Line::raw("  Enter/h/l    open request · collapse/expand group"),
569
        Line::raw("  n/r/d/y      new / rename / delete / duplicate request"),
570
        Line::raw("  /            filter (Enter keeps it, Esc clears)"),
571
        Line::raw("  t            cycle labels: name → summary → path"),
572
        Line::raw(""),
573
        Line::styled(
574
            "Editor (tables)",
575
            Style::default().add_modifier(Modifier::BOLD),
576
        ),
577
        Line::raw("  Enter        send request"),
578
        Line::raw("  i            edit value of selected row"),
579
        Line::raw("  a            add row (key then value)"),
580
        Line::raw("  space        enable/disable row"),
581
        Line::raw("  d            delete row · m cycle method · r rename"),
582
        Line::raw("  p            edit URL / path (paste a full URL to set"),
583
        Line::raw("               the server; ?query fills the Params tab;"),
584
        Line::raw("               a leading verb sets the method, e.g."),
585
        Line::raw("               `post /pets`)"),
586
        Line::raw(""),
587
        Line::styled("Body tab", Style::default().add_modifier(Modifier::BOLD)),
588
        Line::raw("  j/k, d/u     scroll · g/G top/bottom"),
589
        Line::raw("  e            edit in $EDITOR"),
590
        Line::raw(""),
591
        Line::styled("Docs tab", Style::default().add_modifier(Modifier::BOLD)),
592
        Line::raw("  types, enums and descriptions from the spec (* = required)"),
593
        Line::raw("  j/k, d/u     scroll · g/G top/bottom"),
594
        Line::raw(""),
595
        Line::styled("Response", Style::default().add_modifier(Modifier::BOLD)),
596
        Line::raw("  j/k, d/u     scroll · g/G top/bottom"),
597
        Line::raw("  e            open in $EDITOR (view only)"),
598
        Line::raw(""),
599
        Line::styled("Variables", Style::default().add_modifier(Modifier::BOLD)),
600
        Line::raw("  {{name}} in paths, params, headers and bodies —"),
601
        Line::raw("  substituted at send time. Dynamic ones, computed"),
602
        Line::raw("  per send ({{$name}} to bypass a same-named variable):"),
603
    ];
604
    lines.extend(DYNAMIC_VARS.iter().map(|(name, help)| {
605
        Line::from(vec![
606
            Span::styled(format!("  {name:<15}"), Style::default().fg(Color::Magenta)),
607
            Span::raw(*help),
608
        ])
609
    }));
610
    lines.extend([
611
        Line::raw(""),
612
        Line::styled("Commands", Style::default().add_modifier(Modifier::BOLD)),
613
        Line::raw("  :new <name>                      create a collection"),
614
        Line::raw("  :open <name>                     switch collection"),
615
        Line::raw("  :label name|summary|path         sidebar label source"),
616
        Line::raw("  :groups collapsed|expanded       group state on open"),
617
        Line::raw("  :rename-all summary|operation|path|method-path"),
618
    ]);
619
620
    // The list outgrows short terminals, so the popup scrolls with j/k.
621
    let viewport = popup.height.saturating_sub(2) as usize;
622
    let max_scroll = lines.len().saturating_sub(viewport);
623
    app.help_scroll = app.help_scroll.min(max_scroll);
624
    let more = if app.help_scroll < max_scroll {
625
        " Help — j/k scroll · Esc to close "
626
    } else {
627
        " Help — Esc to close "
628
    };
629
    let block = Block::default()
630
        .title(more)
631
        .borders(Borders::ALL)
632
        .border_style(Style::default().fg(Color::Cyan));
633
    f.render_widget(
634
        Paragraph::new(lines)
635
            .block(block)
636
            .scroll((app.help_scroll as u16, 0)),
637
        popup,
638
    );
639
}
640
641
fn draw_env(f: &mut Frame, app: &App, area: Rect) {
642
    let popup = centered(area, 60, 50);
643
    f.render_widget(Clear, popup);
644
    let items: Vec<ListItem> = app
645
        .collection
646
        .servers
647
        .iter()
648
        .enumerate()
649
        .map(|(i, s)| {
650
            let marker = if i == app.collection.active_server {
651
                "● "
652
            } else {
653
                "  "
654
            };
655
            ListItem::new(format!("{marker}{s}"))
656
        })
657
        .collect();
658
    let block = Block::default()
659
        .title(" Servers — Enter: use · a: add · d: delete · Esc: close ")
660
        .borders(Borders::ALL)
661
        .border_style(Style::default().fg(Color::Cyan));
662
    let list = List::new(items)
663
        .block(block)
664
        .highlight_style(Style::default().bg(Color::DarkGray))
665
        .highlight_symbol(">");
666
    let mut state = ListState::default().with_selected(Some(app.env_sel));
667
    f.render_stateful_widget(list, popup, &mut state);
668
}
669
670
fn draw_auth(f: &mut Frame, app: &App, area: Rect) {
671
    use crate::app::AuthField;
672
    use crate::model::{AuthKind, AuthStyle};
673
674
    let popup = centered(area, 70, 45);
675
    f.render_widget(Clear, popup);
676
    let block = Block::default()
677
        .title(" Auth — j/k: field · i/Enter: edit · space: toggle · Esc: save & close ")
678
        .borders(Borders::ALL)
679
        .border_style(Style::default().fg(Color::Cyan));
680
    let inner = block.inner(popup);
681
    f.render_widget(block, popup);
682
683
    let fields = app.auth_fields();
684
    let mut lines = Vec::new();
685
    for (i, field) in fields.iter().enumerate() {
686
        let value = match field {
687
            AuthField::Kind => toggle_row(app.auth_form.kind, AuthKind::ALL, AuthKind::title),
688
            AuthField::Style => toggle_row(
689
                app.auth_form.auth_style,
690
                [AuthStyle::Basic, AuthStyle::Post],
691
                |s| match s {
692
                    AuthStyle::Basic => "basic",
693
                    AuthStyle::Post => "post",
694
                },
695
            ),
696
            f if f.is_secret() && !app.auth_field_value(i).is_empty() => "••••••••".to_string(),
697
            _ => app.auth_field_value(i),
698
        };
699
        let style = if i == app.auth_field {
700
            Style::default()
701
                .bg(Color::DarkGray)
702
                .add_modifier(Modifier::BOLD)
703
        } else {
704
            Style::default()
705
        };
706
        let label = app.auth_field_label(*field);
707
        lines.push(
708
            Line::from(vec![
709
                Span::styled(format!(" {label:<28}"), Style::default().fg(Color::Gray)),
710
                Span::raw(value),
711
            ])
712
            .style(style),
713
        );
714
    }
715
716
    // A hint that secret fields understand `$(…)` command substitution.
717
    if fields.iter().any(|f| f.is_secret()) {
718
        lines.push(Line::raw(""));
719
        lines.push(Line::styled(
720
            " secret fields accept $(cmd), e.g. $(op read \"op://vault/item/field\")",
721
            Style::default().fg(Color::DarkGray),
722
        ));
723
    }
724
725
    f.render_widget(Paragraph::new(lines), inner);
726
}
727
728
/// Render a toggle field as its options with the active one bracketed, e.g.
729
/// `[bearer]  apikey  oauth2`.
730
fn toggle_row<T: PartialEq + Copy, const N: usize>(
731
    current: T,
732
    all: [T; N],
733
    label: impl Fn(T) -> &'static str,
734
) -> String {
735
    all.iter()
736
        .map(|opt| {
737
            let name = label(*opt);
738
            if *opt == current {
739
                format!("[{name}]")
740
            } else {
741
                format!(" {name} ")
742
            }
743
        })
744
        .collect::<Vec<_>>()
745
        .join(" ")
746
}