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