chainview 0.1.2

Terminal UI for option chains, Greeks and volatility - real-time market data and backtest replay in your terminal.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Terminal screens, the pure draw dispatch, and the synchronous render loop
//! (`docs/02-tui-architecture.md` §7, §8, §9).
//!
//! # The draw path is pure over `&App` and does no I/O
//!
//! [`render`] is a **pure function of app state**: it takes `&App` (never
//! `&mut`), lays out the frame, and paints — it never `.await`s, performs I/O, or
//! builds a heavy structure (an `OptionChain`, a `GraphData`, a `TimelineCursor`
//! belong in the domain layer and are *borrowed* by a widget, `docs/02` §7).
//! Because it takes `&App`, a draw cannot mutate app state — the purity guarantee
//! is enforced by the signature.
//!
//! # The dispatch is total and wildcard-free
//!
//! Screen identity is **mode-scoped** ([`LiveScreen`](crate::app::LiveScreen) /
//! [`ReplayScreen`](crate::app::ReplayScreen)), so an out-of-mode pair is
//! unrepresentable and [`render`] is a **total** match: the mode first, then an
//! exhaustive match over that mode's screens, with **no `_` arm**. Adding a screen
//! variant forces the matching mode arm to be revisited by the compiler — the same
//! exhaustiveness discipline `Mode`/`AppEvent` use (`CLAUDE.md` "Key Decisions").
//! A screen that is not reachable is one you can never navigate to
//! ([09](crate::app)/#14), so it never reaches [`render`], and there is no
//! "unavailable" render arm.
//!
//! # This issue's scope (#13)
//!
//! This lands the pure draw dispatch, the placeholder screen bodies, the
//! event-driven render loop ([`driver`]), the two-level key dispatch, and the
//! tick/input task seams the supervisor (#11) owns. The concrete screen bodies —
//! the chain matrix (#18), the theme/keymap/help-overlay **content** (#14), and
//! the render goldens (#19) — land in later issues; the screen `draw`/`handle_key`
//! functions here are honest placeholders (a titled block), never fabricated data.

use ratatui::Frame;
use ratatui::layout::{Constraint, Flex, Layout, Rect};

use crate::app::{App, LiveScreen, Mode, ReplayScreen};
use crate::ui::theme::Theme;
use crate::ui::view::ViewState;

pub mod chain;
pub mod depth;
pub mod driver;
pub mod graph;
pub mod payoff;
pub mod replay;
pub mod surface;
pub mod theme;
pub mod view;

// The render-golden test support (#19): render a screen into a fixed-size
// `TestBackend`, capture the buffer as text, and compare against — or, under
// `UPDATE_GOLDENS=1`, rewrite — a committed golden under
// `tests/render/golden/` (`docs/TESTING.md` §4). Test-only, so it never rides in
// the release binary.
#[cfg(test)]
pub(crate) mod golden;

// ---------------------------------------------------------------------------
// The root layout: status bar (top), screen body (middle), hint line (bottom).
// ---------------------------------------------------------------------------

/// The three regions of the root layout (`docs/05-views-and-ux.md` §8): a
/// one-line status bar on top, the screen body in the middle, and a one-line
/// hint/keybar on the bottom. The help overlay floats over the body.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RootLayout {
    /// The one-line status bar region (top).
    pub status: Rect,
    /// The screen body region (middle) — where the active screen draws.
    pub body: Rect,
    /// The one-line hint/keybar region (bottom).
    pub hint: Rect,
}

/// Split `area` into the status bar, body, and hint line
/// (`docs/05-views-and-ux.md` §8).
///
/// Uses [`Layout::areas`], which yields a fixed-size `[Rect; 3]` — so there is no
/// unchecked index, and a zero-width or zero-height `area` yields zero-size
/// regions the widgets render as empty rather than panicking.
#[must_use]
pub fn layout_root(area: Rect) -> RootLayout {
    let [status, body, hint] = Layout::vertical([
        Constraint::Length(1),
        Constraint::Min(0),
        Constraint::Length(1),
    ])
    .areas(area);
    RootLayout { status, body, hint }
}

// ---------------------------------------------------------------------------
// The total, wildcard-free draw dispatch (§7).
// ---------------------------------------------------------------------------

/// Draw the whole frame from `app` and the ui view cache — the **pure**, total,
/// wildcard-free draw dispatch (`docs/02-tui-architecture.md` §7).
///
/// Takes `&App` and `&ViewState` (never `&mut`), so a draw cannot mutate state or
/// perform I/O — the projected geometry the payoff screen reads was computed off the
/// draw path by [`ViewState::sync`] before this call. The dispatch is a total match
/// — the mode first, then an exhaustive match over that mode's screens with **no
/// `_` arm** — so a new screen forces the matching mode arm to be revisited by the
/// compiler.
///
/// The resolved [`Theme`] (auto/dark/light + `NO_COLOR`) is computed once here and
/// passed to the status bar, keybar, and help overlay. Below the minimum terminal
/// size ([`theme::is_too_small`]) any screen shows the cross-screen "widen the
/// terminal" state instead of a corrupt layout (`docs/05-views-and-ux.md` §8).
pub fn render(app: &App, view: &ViewState, frame: &mut Frame) {
    let area = frame.area();
    let theme = Theme::resolve(app.theme, app.no_color);
    if theme::is_too_small(area) {
        theme::draw_too_small(frame, area, theme);
        return;
    }
    let root = layout_root(area);
    theme::draw_status(app, frame, root.status, theme);
    match &app.mode {
        Mode::Live(state) => match state.screen {
            // The chain matrix reads the resolved theme (so `NO_COLOR` degrades
            // its shading to markers), the tick counter (so its loading spinner
            // advances), and the tick-stamped wall clock `app.now` (so the
            // bid-up/ask-down markers decay on wall-time) — all `Copy`, so the draw
            // stays pure over the borrowed state (`docs/02-tui-architecture.md` §7).
            LiveScreen::Chain => {
                chain::draw(state, frame, root.body, theme, app.tick_count, app.now)
            }
            // The depth ladder reads the resolved theme (so `NO_COLOR` degrades its
            // bid/ask shading to text) and the tick counter (so its loading spinner
            // advances) — both `Copy`, so the draw stays pure over borrowed state (#48).
            LiveScreen::Depth => depth::draw(state, frame, root.body, theme, app.tick_count),
            // The surface line reads only the cached projection the view synced off
            // the draw path — this paint builds no `GraphData` (#47). The tick counter
            // advances its loading spinner (a `Copy` read; the draw stays pure).
            LiveScreen::Surface => surface::draw(
                state,
                view.surface(),
                frame,
                root.body,
                theme,
                app.tick_count,
            ),
            // The payoff line reads only the cached projection the view synced off
            // the draw path — this paint builds no `GraphData` (#27).
            LiveScreen::Payoff => payoff::draw(state, view.payoff(), frame, root.body, theme),
        },
        Mode::Replay(state) => match state.screen {
            // The replay body reads the resolved theme (so `NO_COLOR` degrades to
            // markers), the tick counter (so its loading spinner advances), and the
            // cached equity projection the view synced off the draw path — this paint
            // builds no `GraphData` (#35). All `Copy`/borrowed, so the draw stays pure.
            ReplayScreen::Replay => replay::draw(
                state,
                view.replay(),
                frame,
                root.body,
                theme,
                app.tick_count,
            ),
            // The replay payoff-at-head reads only the cached projection the view
            // synced off the draw path — this paint builds no `GraphData` and prices
            // nothing (#49). All `Copy`/borrowed, so the draw stays pure.
            ReplayScreen::Payoff => {
                payoff::draw_replay(state, view.replay_payoff(), frame, root.body, theme)
            }
        },
    }
    theme::draw_hint(app, frame, root.hint, theme);
    if app.help_open {
        theme::draw_help_overlay(app, frame, root.body, theme);
    }
}

// ---------------------------------------------------------------------------
// Shared centered-rect helper.
// ---------------------------------------------------------------------------

/// A `width`×`height` rectangle centered in `area`, clamped to `area` so it never
/// escapes its bounds. Uses [`Flex::Center`] so there is no manual geometry
/// arithmetic (and no `saturating_*`, per `rules/global_rules.md`). Shared with the
/// theme layer's overlays (`docs/05-views-and-ux.md` §8).
pub(crate) fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
    let [row] = Layout::vertical([Constraint::Length(height)])
        .flex(Flex::Center)
        .areas(area);
    let [cell] = Layout::horizontal([Constraint::Length(width)])
        .flex(Flex::Center)
        .areas(row);
    cell
}

#[cfg(test)]
mod tests {
    use proptest::prelude::*;
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;
    use ratatui::layout::Rect;

    use super::{layout_root, render};
    use crate::app::tests_support::{
        live_app_on, ready_replay_app, ready_replay_app_with_fills, replay_app_on,
    };
    use crate::app::{LiveScreen, Mode, ReplayScreen, ScreenLoad};
    use crate::ui::view::ViewState;

    /// A `TestBackend`-backed terminal for pure render assertions (no runtime, no
    /// real TTY).
    #[track_caller]
    fn terminal(width: u16, height: u16) -> Terminal<TestBackend> {
        match Terminal::new(TestBackend::new(width, height)) {
            Ok(t) => t,
            Err(e) => panic!("TestBackend terminal construction failed: {e}"),
        }
    }

    #[test]
    fn test_layout_root_splits_status_body_hint() {
        let root = layout_root(Rect::new(0, 0, 80, 24));
        assert_eq!(root.status.height, 1, "the status bar is one line");
        assert_eq!(root.hint.height, 1, "the hint line is one line");
        assert_eq!(root.body.height, 22, "the body takes the remaining rows");
        assert_eq!(root.status.y, 0);
        assert_eq!(root.body.y, 1);
        assert_eq!(root.hint.y, 23);
    }

    #[test]
    fn test_layout_root_zero_area_yields_zero_regions_without_panic() {
        // A zero-size area must not panic (no unchecked index into the split).
        let root = layout_root(Rect::new(0, 0, 0, 0));
        assert_eq!(root.status.width, 0);
        assert_eq!(root.body.height, 0);
    }

    #[test]
    fn test_render_is_pure_does_not_mutate_app() {
        // `render` takes `&App` + `&ViewState`, so a draw cannot mutate state — the
        // purity guarantee is enforced by the signatures. This test documents it by
        // asserting the observable app state is byte-for-byte unchanged across a
        // draw (dirty/help/quit/screen) and that the payoff projection is stable
        // (the geometry is projected off the draw path by `ViewState::sync`, never
        // in `draw`).
        let app = live_app_on(LiveScreen::Payoff, ScreenLoad::Loading, true);
        let mut view = ViewState::new();
        view.sync(&app);
        let before_projection = view.payoff().clone();
        let before = (app.dirty, app.help_open, app.should_quit);
        let before_screen = match &app.mode {
            Mode::Live(s) => s.screen,
            Mode::Replay(_) => panic!("expected a live app"),
        };
        let mut terminal = terminal(80, 24);
        match terminal.draw(|frame| render(&app, &view, frame)) {
            Ok(_) => {}
            Err(e) => panic!("draw failed: {e}"),
        }
        let after = (app.dirty, app.help_open, app.should_quit);
        let after_screen = match &app.mode {
            Mode::Live(s) => s.screen,
            Mode::Replay(_) => panic!("expected a live app"),
        };
        assert_eq!(before, after, "render must not mutate flags");
        assert_eq!(
            before_screen, after_screen,
            "render must not switch screens"
        );
        assert_eq!(
            &before_projection,
            view.payoff(),
            "render must not rebuild or mutate the payoff projection",
        );
    }

    #[test]
    fn test_render_help_overlay_open_and_closed_never_panics() {
        for help in [false, true] {
            let app = live_app_on(LiveScreen::Chain, ScreenLoad::Loading, help);
            let mut view = ViewState::new();
            view.sync(&app);
            let mut terminal = terminal(100, 30);
            match terminal.draw(|frame| render(&app, &view, frame)) {
                Ok(_) => {}
                Err(e) => panic!("draw failed (help={help}): {e}"),
            }
        }
    }

    #[test]
    fn test_render_every_reachable_live_screen_never_panics() {
        let screens = [
            LiveScreen::Chain,
            LiveScreen::Depth,
            LiveScreen::Surface,
            LiveScreen::Payoff,
        ];
        let loads = [
            ScreenLoad::Loading,
            ScreenLoad::Ready,
            ScreenLoad::Error {
                message: "provider unreachable".to_owned(),
            },
        ];
        for screen in screens {
            for load in &loads {
                let app = live_app_on(screen, load.clone(), false);
                let mut view = ViewState::new();
                view.sync(&app);
                let mut terminal = terminal(120, 40);
                match terminal.draw(|frame| render(&app, &view, frame)) {
                    Ok(_) => {}
                    Err(e) => panic!("draw failed ({screen:?}/{load:?}): {e}"),
                }
            }
        }
    }

    #[test]
    fn test_render_every_reachable_replay_screen_never_panics() {
        for screen in [ReplayScreen::Replay, ReplayScreen::Payoff] {
            let app = replay_app_on(screen, false);
            let mut view = ViewState::new();
            view.sync(&app);
            let mut terminal = terminal(120, 40);
            match terminal.draw(|frame| render(&app, &view, frame)) {
                Ok(_) => {}
                Err(e) => panic!("draw failed ({screen:?}): {e}"),
            }
        }
    }

    #[test]
    fn test_render_ready_replay_screen_never_panics() {
        // The populated Ready body (equity + drawdown, attribution, drill-down) and
        // the empty-run Ready body both render through the full loop — the projection
        // is synced off the draw path, and the paint stays pure (#35).
        let (with_fills, _rx1) = ready_replay_app_with_fills(6);
        let (empty_run, _rx2) = ready_replay_app(0);
        for app in [&with_fills, &empty_run] {
            let mut view = ViewState::new();
            view.sync(app);
            for (w, h) in [(1u16, 1u16), (40, 8), (120, 40)] {
                let mut terminal = terminal(w, h);
                match terminal.draw(|frame| render(app, &view, frame)) {
                    Ok(_) => {}
                    Err(e) => panic!("ready replay render failed at {w}x{h}: {e}"),
                }
            }
        }
    }

    proptest! {
        #![proptest_config(ProptestConfig { cases: 256, max_shrink_iters: 50_000, ..ProptestConfig::default() })]

        /// Drawing **any reachable app state** into a `TestBackend` never panics
        /// (`docs/TESTING.md` §3 `render_never_panics`). The generated state space
        /// enumerates: both modes; every reachable screen (all four `LiveScreen`s,
        /// both `ReplayScreen`s); help open and closed; every live load state
        /// (`Loading` / `Ready` / `Error`); and any terminal size from 1x1 up —
        /// stressing the near-zero-area layout, the centered help overlay, and the
        /// truncating status/hint lines. The assertion is that the draw completes
        /// (a panic — an unchecked index, an out-of-bounds rect — fails the case).
        #[test]
        fn prop_render_never_panics(
            mode_idx in 0u8..2,
            live_screen_idx in 0u8..4,
            replay_screen_idx in 0u8..2,
            load_idx in 0u8..3,
            help in any::<bool>(),
            width in 1u16..160,
            height in 1u16..60,
        ) {
            let app = if mode_idx == 0 {
                let screen = match live_screen_idx {
                    0 => LiveScreen::Chain,
                    1 => LiveScreen::Depth,
                    2 => LiveScreen::Surface,
                    _ => LiveScreen::Payoff,
                };
                let load = match load_idx {
                    0 => ScreenLoad::Loading,
                    1 => ScreenLoad::Ready,
                    _ => ScreenLoad::Error {
                        message: "provider unreachable".to_owned(),
                    },
                };
                live_app_on(screen, load, help)
            } else {
                let screen = if replay_screen_idx == 0 {
                    ReplayScreen::Replay
                } else {
                    ReplayScreen::Payoff
                };
                replay_app_on(screen, help)
            };
            let mut view = ViewState::new();
            view.sync(&app);
            let mut terminal = terminal(width, height);
            match terminal.draw(|frame| render(&app, &view, frame)) {
                Ok(_) => {}
                Err(e) => prop_assert!(false, "draw failed at {width}x{height}: {e}"),
            }
        }
    }
}