Skip to main content

wisp/renderer/
mod.rs

1//! Frame rendering.
2//!
3//! [`Renderer`] is the facade: it owns the drawing services (theme, syntax
4//! highlighter) and every cache, and sequences one draw. The rest of this
5//! module layers below it:
6//!
7//! - `layout` measures the frame's bands once per draw;
8//! - `history` moves overflow rows into the terminal's native scrollback;
9//! - `transcript` turns conversation items into rows, through the `cache` of
10//!   sealed items and the `streaming` incremental renderer;
11//! - `frame` places the bands, composer, status line, and overlays.
12//!
13//! The pure item→rows builders live with their models in
14//! `crate::conversation::{item_view, tool_view}`.
15
16mod cache;
17mod frame;
18mod history;
19mod layout;
20mod stats;
21mod streaming;
22mod transcript;
23
24use std::collections::HashMap;
25
26use crate::app::App;
27use crate::conversation::{ConversationId, ConversationItemId};
28use crate::error::RenderError;
29use crate::theme::Theme;
30use crate::view::generation::Generation;
31use crate::view::syntax::SyntaxHighlighter;
32use frame::draw_frame;
33use ratatui::Terminal;
34use ratatui::backend::Backend;
35
36use cache::RenderCache;
37use history::NativeHistoryCursor;
38use layout::FrameLayout;
39use stats::Lap;
40pub use stats::RenderStats;
41use streaming::StreamEntry;
42
43/// Shared rendering services, borrowed for the duration of one draw.
44///
45/// `theme_generation` lets a route or overlay cache styled text across frames and
46/// rebuild it only when the theme actually changes.
47pub struct DrawContext<'a> {
48    pub theme: &'a Theme,
49    pub highlighter: &'a mut SyntaxHighlighter,
50    pub theme_generation: Generation,
51}
52
53/// Owns everything drawing a frame needs: the theme, the syntax highlighter,
54/// and the caches that keep streaming content stable between frames.
55///
56/// Methods destructure `self` rather than taking `&self.theme` through `&mut
57/// self`, so the theme is borrowed alongside the highlighter instead of cloned
58/// once per item per frame.
59pub struct Renderer {
60    theme: Theme,
61    highlighter: SyntaxHighlighter,
62    theme_generation: Option<Generation>,
63    render_cache: RenderCache,
64    native_history: NativeHistoryCursor,
65    stream_cache: HashMap<ConversationItemId, StreamEntry>,
66    stats: RenderStats,
67}
68
69impl Default for Renderer {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl Renderer {
76    pub fn new() -> Self {
77        Self {
78            theme: Theme::default(),
79            highlighter: SyntaxHighlighter::new(),
80            theme_generation: None,
81            render_cache: RenderCache::default(),
82            native_history: NativeHistoryCursor::default(),
83            stream_cache: HashMap::new(),
84            stats: RenderStats::default(),
85        }
86    }
87
88    pub fn take_stats(&mut self) -> RenderStats {
89        let highlight = self.highlighter.take_stats();
90        RenderStats { highlight, ..std::mem::take(&mut self.stats) }
91    }
92
93    pub fn theme(&self) -> &Theme {
94        &self.theme
95    }
96
97    /// The app owns the active theme; adopt it whenever its generation moves,
98    /// dropping every cache styled with the old one.
99    fn sync_theme(&mut self, app: &App) {
100        if self.theme_generation != Some(app.theme_generation()) {
101            self.theme = app.theme().clone();
102            self.theme_generation = Some(app.theme_generation());
103            self.render_cache.clear();
104        }
105    }
106
107    fn generation(&self) -> Generation {
108        self.theme_generation.unwrap_or_default()
109    }
110
111    pub fn draw<B: Backend>(&mut self, terminal: &mut Terminal<B>, app: &mut App) -> Result<(), RenderError<B::Error>> {
112        self.sync_theme(app);
113        terminal.autoresize().map_err(RenderError::Backend)?;
114        let area = terminal.get_frame().area();
115        self.sync_conversation(app.conversation_id());
116        self.reconcile_history(terminal, app)?;
117
118        let lap = Lap::start();
119        let layout = FrameLayout::new(area, app, self);
120        let capacity = usize::from(layout.transcript_height).saturating_sub(usize::from(layout.progress_height));
121        self.stats.ns_layout += lap.ns();
122
123        let lap = Lap::start();
124        let live = self.commit_overflow(terminal, app, area.width, capacity)?;
125        self.stats.ns_live += lap.ns();
126
127        let live = if app.full_screen_active() { Vec::new() } else { live };
128        let lap = Lap::start();
129        terminal.draw(|frame| draw_frame(frame, app, self, &layout, &live)).map_err(RenderError::Backend)?;
130        self.stats.ns_draw += lap.ns();
131        self.stats.frames += 1;
132        self.render_cache.current = std::mem::take(&mut self.render_cache.frame);
133        Ok(())
134    }
135
136    /// The rendering services a full-screen route or overlay draws with.
137    fn context(&mut self) -> DrawContext<'_> {
138        let theme_generation = self.generation();
139        DrawContext { theme: &self.theme, highlighter: &mut self.highlighter, theme_generation }
140    }
141
142    fn sync_conversation(&mut self, conversation_id: ConversationId) {
143        if self.native_history.conversation_id != Some(conversation_id) {
144            self.native_history =
145                NativeHistoryCursor { conversation_id: Some(conversation_id), ..NativeHistoryCursor::default() };
146            self.render_cache.clear();
147            self.stream_cache.clear();
148        }
149    }
150}