chore: removed tuitextarea 4c324c98
Steve Simkins · 2026-08-08 23:18 9 file(s) · +79 −123
AGENTS.md +7 −7
67 67
- **`Method::parse`, not `FromStr`** — deliberately not the trait, to dodge
68 68
  a clippy lint; nothing else depends on `FromStr`.
69 69
- **Vim modes are `Normal` / `Insert` / `Command` / `Search`** — no Visual
70 -
  mode. Insert mode is reused for both single-line field edits (`LineEdit`)
71 -
  and the body `TextArea`; `app.editing` discriminates which. `Search` is the
70 +
  mode. Insert mode is only single-line field edits (`LineEdit`), tracked by
71 +
  `app.editing`; there is no in-app multi-line editor. `Search` is the
72 72
  `/` sidebar filter: it re-applies on every keystroke, and `app.filter` (the
73 73
  committed query) is deliberately separate from `app.search` (the live
74 74
  prompt buffer) so `Esc` can drop the prompt without touching the filter.
111 111
  drops input: every character comes back out in some span (there's a test).
112 112
  A `syntect`-class dependency would be larger than the rest of the binary,
113 113
  and JSON/XML/plain is all a request client shows.
114 -
- **The body has two renderers.** `tui-textarea` styles whole lines only, so
115 -
  the Body tab renders a highlighted `Paragraph` in Normal mode and the raw
116 -
  `TextArea` in Insert mode. The textarea stays the source of truth either
117 -
  way; the read-only view scrolls by moving *its* cursor, which is why `j`/`k`
118 -
  on the Body tab drive `CursorMove`.
114 +
- **The body is read-only in the TUI.** It renders as a highlighted
115 +
  `Paragraph`; edits go through `$EDITOR` (`e`). `app.body_text` is the source
116 +
  of truth and `app.body_scroll` is a plain offset clamped at render, which is
117 +
  why `j`/`k` on the Body tab just move that offset. There is deliberately no
118 +
  in-app text editor — that's what dropped the `tui-textarea` dependency.
119 119
- **Dynamic variables live in the `{{…}}` namespace**, not a second syntax:
120 120
  `{{uuid}}`, `{{randomInt(1,10)}}`, `{{isoTimestamp}}`. A collection variable
121 121
  shadows a dynamic one of the same name (`{{$name}}` forces the dynamic one),
Cargo.lock +0 −12
202 202
 "tempfile",
203 203
 "thiserror",
204 204
 "tokio",
205 -
 "tui-textarea",
206 205
 "url",
207 206
 "uuid",
208 207
 "wiremock",
1990 1989
version = "0.2.5"
1991 1990
source = "registry+https://github.com/rust-lang/crates.io-index"
1992 1991
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
1993 -
1994 -
[[package]]
1995 -
name = "tui-textarea"
1996 -
version = "0.7.0"
1997 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1998 -
checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
1999 -
dependencies = [
2000 -
 "crossterm",
2001 -
 "ratatui",
2002 -
 "unicode-width 0.2.0",
2003 -
]
2004 1992
2005 1993
[[package]]
2006 1994
name = "unicode-ident"
Cargo.toml +0 −1
29 29
serde_yaml = "0.9.34"
30 30
thiserror = "2.0.19"
31 31
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] }
32 -
tui-textarea = "0.7.0"
33 32
url = "2.5.8"
34 33
uuid = { version = "1.24.0", features = ["v4", "serde"] }
35 34
README.md +1 −1
85 85
86 86
Tables: `space` toggle row, `i` edit, `a` add, `d` delete, `m` cycle method, `p` edit URL. 
87 87
88 -
Body/response: `i` edit inline, `e` open in `$EDITOR`, `j`/`k`/`d`/`u`/`g`/`G` scroll.
88 +
Body/response: `e` open in `$EDITOR`, `j`/`k`/`d`/`u`/`g`/`G` scroll.
89 89
90 90
Press `?` in the TUI for the full list.
91 91
src/app.rs +13 −17
16 16
use ratatui::backend::CrosstermBackend;
17 17
use serde::{Deserialize, Serialize};
18 18
use tokio::sync::mpsc;
19 -
use tui_textarea::TextArea;
20 19
use uuid::Uuid;
21 20
22 21
use crate::http::{HttpResponse, OAuthToken, SendOutcome, send_with_auth, split_url_input};
314 313
    pub input: LineEdit,
315 314
    /// After committing a new row's key, continue to its value cell.
316 315
    pub chain_to_value: bool,
317 -
    pub textarea: TextArea<'static>,
316 +
    /// Request body text — source of truth. Edited only via `$EDITOR`.
317 +
    pub body_text: String,
318 +
    /// Scroll offset of the read-only Body view, clamped at render.
319 +
    pub body_scroll: usize,
318 320
319 321
    /// Scroll offset of the Docs tab, reset when another request is opened.
320 322
    pub docs_scroll: usize,
367 369
            editing: None,
368 370
            input: LineEdit::default(),
369 371
            chain_to_value: false,
370 -
            textarea: TextArea::default(),
372 +
            body_text: String::new(),
373 +
            body_scroll: 0,
371 374
            docs_scroll: 0,
372 375
            response: None,
373 376
            response_scroll: 0,
562 565
            .body
563 566
            .clone()
564 567
            .unwrap_or_default();
565 -
        self.set_textarea_text(&body);
568 +
        self.set_body_text(&body);
566 569
        self.focus = Focus::Editor;
567 570
    }
568 571
569 -
    pub fn set_textarea_text(&mut self, text: &str) {
570 -
        let lines: Vec<String> = if text.is_empty() {
571 -
            vec![String::new()]
572 -
        } else {
573 -
            text.lines().map(String::from).collect()
574 -
        };
575 -
        self.textarea = TextArea::from(lines);
576 -
        self.textarea
577 -
            .set_cursor_line_style(ratatui::style::Style::default());
572 +
    pub fn set_body_text(&mut self, text: &str) {
573 +
        self.body_text = text.to_string();
574 +
        self.body_scroll = 0;
578 575
    }
579 576
580 -
    /// Write the textarea contents back into the selected request body.
577 +
    /// Write the body text back into the selected request body.
581 578
    pub fn commit_body(&mut self) {
582 579
        let Some(idx) = self.selected else { return };
583 -
        let text = self.textarea.lines().join("\n");
584 -
        let text = text.trim_end_matches('\n').to_string();
580 +
        let text = self.body_text.trim_end_matches('\n').to_string();
585 581
        let req = &mut self.collection.requests[idx];
586 582
        let new = if text.trim().is_empty() {
587 583
            None
1463 1459
                app.collection.requests[i].body = Some(content.clone());
1464 1460
                app.dirty = true;
1465 1461
            }
1466 -
            app.set_textarea_text(&content);
1462 +
            app.set_body_text(&content);
1467 1463
            app.status = "Body updated from editor".into();
1468 1464
        }
1469 1465
        (ExternalEdit::Body, Ok(s)) => {
src/input.rs +24 −38
1 1
//! Vim-style key handling.
2 2
3 3
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4 -
use tui_textarea::CursorMove;
5 4
6 5
use crate::app::{
7 6
    App, CellCol, EditTarget, EditorTab, ExternalEdit, Focus, Mode, Popup, SidebarRow,
126 125
                app.dirty = true;
127 126
                if app.selected == Some(idx) {
128 127
                    app.selected = None;
129 -
                    app.set_textarea_text("");
128 +
                    app.set_body_text("");
130 129
                }
131 130
                app.selected = app.selected.map(|s| if s > idx { s - 1 } else { s });
132 131
                app.rebuild_sidebar();
177 176
                app.toggle_row(t, app.table_row);
178 177
            }
179 178
        }
179 +
        // Body has no in-app editor — use `e` for `$EDITOR`. Tables edit inline.
180 180
        KeyCode::Char('i') | KeyCode::Char('a') => {
181 181
            let is_add = key.code == KeyCode::Char('a');
182 -
            match app.tab {
183 -
                EditorTab::Body => {
184 -
                    app.mode = Mode::Insert;
185 -
                    app.status = "Editing body — Esc to finish".into();
186 -
                }
187 -
                _ => {
188 -
                    if let Some(t) = app.tab.table() {
189 -
                        if is_add {
190 -
                            app.add_row(t);
191 -
                        } else if app.table_row < app.table_len(t) {
192 -
                            app.chain_to_value = false;
193 -
                            app.start_edit(EditTarget::Cell {
194 -
                                table: t,
195 -
                                row: app.table_row,
196 -
                                col: CellCol::Value,
197 -
                            });
198 -
                        }
199 -
                    }
182 +
            if let Some(t) = app.tab.table() {
183 +
                if is_add {
184 +
                    app.add_row(t);
185 +
                } else if app.table_row < app.table_len(t) {
186 +
                    app.chain_to_value = false;
187 +
                    app.start_edit(EditTarget::Cell {
188 +
                        table: t,
189 +
                        row: app.table_row,
190 +
                        col: CellCol::Value,
191 +
                    });
200 192
                }
201 193
            }
202 194
        }
220 212
    }
221 213
}
222 214
223 -
/// Body-tab movement. The read-only (highlighted) body view follows the
224 -
/// textarea's cursor, so scrolling it is just moving that cursor. Returns
225 -
/// whether the key was consumed.
215 +
/// Body-tab movement. The read-only body view is a plain scroll offset,
216 +
/// clamped against the rendered height in `ui::draw_body`. Returns whether
217 +
/// the key was consumed.
226 218
fn body_scroll(app: &mut App, key: KeyEvent) -> bool {
227 -
    let moves: &[CursorMove] = match key.code {
228 -
        KeyCode::Char('j') | KeyCode::Down => &[CursorMove::Down],
229 -
        KeyCode::Char('k') | KeyCode::Up => &[CursorMove::Up],
230 -
        KeyCode::Char('g') => &[CursorMove::Top],
231 -
        KeyCode::Char('G') => &[CursorMove::Bottom],
232 -
        KeyCode::Char('d') => &[CursorMove::Down; 15],
233 -
        KeyCode::Char('u') => &[CursorMove::Up; 15],
219 +
    match key.code {
220 +
        KeyCode::Char('j') | KeyCode::Down => app.body_scroll += 1,
221 +
        KeyCode::Char('k') | KeyCode::Up => app.body_scroll = app.body_scroll.saturating_sub(1),
222 +
        KeyCode::Char('g') => app.body_scroll = 0,
223 +
        KeyCode::Char('G') => app.body_scroll = usize::MAX / 2,
224 +
        KeyCode::Char('d') => app.body_scroll += 15,
225 +
        KeyCode::Char('u') => app.body_scroll = app.body_scroll.saturating_sub(15),
234 226
        _ => return false,
235 -
    };
236 -
    for m in moves {
237 -
        app.textarea.move_cursor(*m);
238 227
    }
239 228
    true
240 229
}
305 294
        }
306 295
        return;
307 296
    }
308 -
    // Body textarea editing.
297 +
    // No in-app body editor: body is edited via `$EDITOR` (`e`). Any stray
298 +
    // Insert-mode key just returns to Normal.
309 299
    if key.code == KeyCode::Esc {
310 -
        app.commit_body();
311 300
        app.mode = Mode::Normal;
312 -
        app.status = "Body updated".into();
313 -
        return;
314 301
    }
315 -
    app.textarea.input(key);
316 302
}
317 303
318 304
// ----- Search mode (sidebar filter) -----
src/ui.rs +11 −27
221 221
    }
222 222
}
223 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.
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.
228 226
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 227
    let block = Block::default()
241 -
        .title(" Body — i: edit · e: $EDITOR · j/k: scroll ")
228 +
        .title(" Body — e: $EDITOR · j/k: scroll ")
242 229
        .borders(Borders::NONE);
243 230
    let inner = block.inner(area);
244 231
    f.render_widget(block, area);
245 232
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);
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 +
    );
255 240
}
256 241
257 242
/// Read-only view of what the spec says about this request: the operation
601 586
        Line::raw(""),
602 587
        Line::styled("Body tab", Style::default().add_modifier(Modifier::BOLD)),
603 588
        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"),
589 +
        Line::raw("  e            edit in $EDITOR"),
606 590
        Line::raw(""),
607 591
        Line::styled("Docs tab", Style::default().add_modifier(Modifier::BOLD)),
608 592
        Line::raw("  types, enums and descriptions from the spec (* = required)"),
tests/input_tests.rs +16 −15
200 200
    handle_key(&mut app, key(KeyCode::Enter));
201 201
    assert_eq!(app.selected, Some(1)); // createPet
202 202
    assert_eq!(app.focus, Focus::Editor);
203 -
    // body loaded into textarea
204 -
    assert!(app.textarea.lines().join("\n").contains("Fido"));
203 +
    // body loaded into the body view
204 +
    assert!(app.body_text.contains("Fido"));
205 205
}
206 206
207 207
#[test]
417 417
}
418 418
419 419
#[test]
420 -
fn body_textarea_editing() {
420 +
fn body_has_no_inline_editor() {
421 421
    let mut app = test_app();
422 422
    // select createPet (has a body)
423 423
    app.select_request(1);
424 424
    handle_key(&mut app, char_key(']'));
425 425
    handle_key(&mut app, char_key(']')); // Body tab
426 426
    assert_eq!(app.tab, EditorTab::Body);
427 +
    // `i` no longer opens an in-app editor; the body is edited via `$EDITOR`.
427 428
    handle_key(&mut app, char_key('i'));
428 -
    assert_eq!(app.mode, Mode::Insert);
429 -
    handle_key(&mut app, key(KeyCode::Esc));
430 429
    assert_eq!(app.mode, Mode::Normal);
430 +
    // `e` queues an external edit instead.
431 +
    handle_key(&mut app, char_key('e'));
432 +
    assert_eq!(app.pending_external, Some(cielago::app::ExternalEdit::Body));
431 433
    assert!(
432 434
        app.collection.requests[1]
433 435
            .body
441 443
fn body_tab_scrolls_the_read_only_view() {
442 444
    let mut app = test_app();
443 445
    app.select_request(1);
444 -
    app.set_textarea_text(&(1..=40).map(|i| format!("line {i}\n")).collect::<String>());
446 +
    app.set_body_text(&(1..=40).map(|i| format!("line {i}\n")).collect::<String>());
445 447
    app.tab = EditorTab::Body;
446 448
447 -
    // The highlighted body view follows the textarea cursor.
449 +
    // The read-only body view scrolls with a plain offset.
448 450
    handle_key(&mut app, char_key('j'));
449 451
    handle_key(&mut app, char_key('j'));
450 -
    assert_eq!(app.textarea.cursor().0, 2);
452 +
    assert_eq!(app.body_scroll, 2);
451 453
    handle_key(&mut app, char_key('k'));
452 -
    assert_eq!(app.textarea.cursor().0, 1);
454 +
    assert_eq!(app.body_scroll, 1);
453 455
    handle_key(&mut app, char_key('d'));
454 -
    assert_eq!(app.textarea.cursor().0, 16);
456 +
    assert_eq!(app.body_scroll, 16);
455 457
    handle_key(&mut app, char_key('u'));
456 -
    assert_eq!(app.textarea.cursor().0, 1);
457 -
    handle_key(&mut app, char_key('G'));
458 -
    assert!(app.textarea.cursor().0 >= 39);
458 +
    assert_eq!(app.body_scroll, 1);
459 459
    handle_key(&mut app, char_key('g'));
460 -
    assert_eq!(app.textarea.cursor().0, 0);
460 +
    assert_eq!(app.body_scroll, 0);
461 461
462 462
    // `d` scrolls here rather than deleting a row, but the other editor keys
463 463
    // still reach their handlers.
464 464
    assert_eq!(app.collection.requests[1].method, Method::Post);
465 465
    handle_key(&mut app, char_key('m'));
466 466
    assert_eq!(app.collection.requests[1].method, Method::Put);
467 +
    // `i` on the Body tab is a no-op, not an editor.
467 468
    handle_key(&mut app, char_key('i'));
468 -
    assert_eq!(app.mode, Mode::Insert);
469 +
    assert_eq!(app.mode, Mode::Normal);
469 470
}
470 471
471 472
#[test]
tests/ui_tests.rs +7 −5
77 77
}
78 78
79 79
#[test]
80 -
fn body_falls_back_to_the_plain_textarea_while_editing() {
80 +
fn body_stays_highlighted_and_read_only() {
81 +
    // There is no in-app body editor — the body is edited via `$EDITOR`, so it
82 +
    // renders syntax-highlighted regardless of mode.
81 83
    let mut app = test_app();
82 84
    app.tab = EditorTab::Body;
83 85
    app.mode = Mode::Insert;
84 86
    let buf = render(&mut app, 100, 40);
85 87
86 88
    assert!(screen(&buf).contains("\"name\""));
87 -
    assert_eq!(fg_of(&buf, "\"name\""), Some(Color::Reset));
89 +
    assert_eq!(fg_of(&buf, "\"name\""), Some(Color::Cyan));
88 90
}
89 91
90 92
#[test]
181 183
    app.tab = EditorTab::Body;
182 184
    render(&mut app, 20, 8);
183 185
184 -
    app.set_textarea_text("");
186 +
    app.set_body_text("");
185 187
    render(&mut app, 100, 40);
186 188
187 -
    app.set_textarea_text(&(1..=200).map(|i| format!("[{i}]\n")).collect::<String>());
188 -
    app.textarea.move_cursor(tui_textarea::CursorMove::Bottom);
189 +
    app.set_body_text(&(1..=200).map(|i| format!("[{i}]\n")).collect::<String>());
190 +
    app.body_scroll = usize::MAX / 2;
189 191
    render(&mut app, 100, 40);
190 192
191 193
    app.tab = EditorTab::Docs;