Skip to main content

typ_core/
panel.rs

1use std::any::Any;
2
3use crossterm::event::MouseEvent;
4use ratatui::buffer::Buffer;
5use ratatui::layout::Rect;
6use ratatui::style::Color;
7
8use crate::{KeyChord, PanelEvent};
9
10/// The shipped palette, as a named ramp rather than a colour per widget.
11///
12/// Architecture §4 asks for "one visual system applied uniformly", and a
13/// palette assembled colour-by-colour as each widget needed one is how that
14/// promise gets broken quietly — nothing is ever *wrong*, the greys just drift
15/// apart until the editor looks assembled rather than designed.
16///
17/// So every neutral here is a step on **one ramp at one hue** — a cool
18/// blue-grey near 218° — and every accent is placed against that ramp
19/// deliberately. Widgets name a step; they never mix their own.
20///
21/// The steps are ordered dark to light and each has a job:
22///
23/// | Step | Job |
24/// |---|---|
25/// | 00 | the page |
26/// | 01 | the cursor's line — one step, felt rather than seen |
27/// | 02 | raised surfaces: the status bar |
28/// | 03 | borders and rules |
29/// | 04 | furniture text: line numbers |
30/// | 05 | quiet content: inactive status segments |
31/// | 06 | secondary content: file names |
32/// | 07 | body text |
33/// | 08 | text on a selection |
34///
35/// Contrast is checked rather than eyeballed — see `typ-core/tests/theme.rs`,
36/// which computes WCAG ratios from these channel values and fails the build if
37/// a change drops body text below 7:1 or the gutter below 3:1.
38mod palette {
39    use ratatui::style::Color;
40
41    pub const BASE_00: Color = Color::Rgb(0x10, 0x14, 0x1b);
42    pub const BASE_01: Color = Color::Rgb(0x16, 0x1c, 0x25);
43    pub const BASE_02: Color = Color::Rgb(0x1a, 0x21, 0x2c);
44    pub const BASE_03: Color = Color::Rgb(0x2a, 0x32, 0x40);
45    pub const BASE_04: Color = Color::Rgb(0x5a, 0x6a, 0x80);
46    pub const BASE_05: Color = Color::Rgb(0x6b, 0x7b, 0x91);
47    pub const BASE_06: Color = Color::Rgb(0xa8, 0xb3, 0xc4);
48    pub const BASE_07: Color = Color::Rgb(0xc8, 0xd0, 0xdc);
49    pub const BASE_08: Color = Color::Rgb(0xe6, 0xec, 0xf5);
50
51    /// The one accent. Focus, links, and anything the eye should be drawn to.
52    pub const ACCENT: Color = Color::Rgb(0x4f, 0x8c, 0xc9);
53    /// The same hue, lifted — directories in the tree.
54    pub const ACCENT_BRIGHT: Color = Color::Rgb(0x7f, 0xb3, 0xe0);
55
56    /// Selections sit on the accent's hue at two depths, so the primary reads
57    /// as "the same thing, more so" rather than as a different feature.
58    pub const SELECT: Color = Color::Rgb(0x26, 0x36, 0x4d);
59    pub const SELECT_PRIMARY: Color = Color::Rgb(0x35, 0x50, 0x7a);
60
61    /// Semantic colours. Deliberately *not* on the base hue: these mean
62    /// something, and a reader must not have to decide whether a colour is
63    /// decoration or information.
64    pub const RED: Color = Color::Rgb(0xe0, 0x6c, 0x75);
65    pub const AMBER: Color = Color::Rgb(0xe5, 0xc0, 0x7b);
66    pub const AMBER_DEEP: Color = Color::Rgb(0x3a, 0x35, 0x24);
67    pub const TEAL: Color = Color::Rgb(0x56, 0xb6, 0xc2);
68}
69
70/// The colors a panel is allowed to know about.
71///
72/// Deliberately a small copy rather than a reference to a full theme: panels
73/// should not be able to reach into application state through their theme.
74///
75/// Modelled on Helix's `ui.*` scopes, which number 40-plus. This takes the ones
76/// TYPE has a use for now or at M3 and no more — but it does take the M3 ones,
77/// because a theme file written at M2.5 without diagnostic colours is a theme
78/// file that gets a breaking change the moment the LSP client lands.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct ThemeColors {
81    pub fg: Color,
82    pub bg: Color,
83    /// The cursor's line. One step off the page — a highlight strong enough to
84    /// find deliberately is strong enough to be a stripe across the screen.
85    pub cursor_line_bg: Color,
86
87    pub gutter_fg: Color,
88    pub gutter_bg: Color,
89    pub line_number_fg: Color,
90    pub line_number_current_fg: Color,
91
92    pub selection_bg: Color,
93    pub selection_fg: Color,
94    /// The primary selection, the one every motion is relative to. Helix themes
95    /// this separately for exactly that reason: with thirty cursors, nothing
96    /// else says which one is being steered.
97    pub selection_primary_bg: Color,
98
99    pub bracket_match_fg: Color,
100    pub bracket_match_bg: Color,
101
102    pub border: Color,
103    pub border_focused: Color,
104
105    pub status_bar_bg: Color,
106    pub status_bar_fg: Color,
107    /// Segments carrying real but secondary content — filetype, line ending.
108    /// Quieter than `status_bar_fg`, never so quiet it stops being readable.
109    pub status_bar_inactive_fg: Color,
110    pub status_bar_accent: Color,
111
112    pub tree_directory_fg: Color,
113    pub tree_file_fg: Color,
114
115    /// Unused until M3. Four lines now against a breaking change to every
116    /// shipped theme file later.
117    pub diagnostic_error: Color,
118    pub diagnostic_warning: Color,
119    pub diagnostic_info: Color,
120    pub diagnostic_hint: Color,
121}
122
123impl Default for ThemeColors {
124    fn default() -> Self {
125        use palette as p;
126        Self {
127            fg: p::BASE_07,
128            bg: p::BASE_00,
129            cursor_line_bg: p::BASE_01,
130
131            gutter_fg: p::BASE_04,
132            // The gutter shares the page's background rather than having one of
133            // its own: a seam down the left of every file is chrome doing a job
134            // the digits already do.
135            gutter_bg: p::BASE_00,
136            line_number_fg: p::BASE_04,
137            // The current line's number matches body text — "here" is stated by
138            // being as present as the code, not by being tinted.
139            line_number_current_fg: p::BASE_07,
140
141            selection_bg: p::SELECT,
142            selection_fg: p::BASE_08,
143            selection_primary_bg: p::SELECT_PRIMARY,
144
145            bracket_match_fg: p::AMBER,
146            bracket_match_bg: p::AMBER_DEEP,
147
148            border: p::BASE_03,
149            border_focused: p::ACCENT,
150
151            status_bar_bg: p::BASE_02,
152            status_bar_fg: p::BASE_07,
153            status_bar_inactive_fg: p::BASE_05,
154            status_bar_accent: p::ACCENT,
155
156            tree_directory_fg: p::ACCENT_BRIGHT,
157            tree_file_fg: p::BASE_06,
158
159            diagnostic_error: p::RED,
160            diagnostic_warning: p::AMBER,
161            diagnostic_info: p::ACCENT,
162            diagnostic_hint: p::TEAL,
163        }
164    }
165}
166
167/// Everything a panel may see at render time.
168///
169/// This is the whole surface — a panel never receives `&AppState`.
170pub struct RenderContext<'a> {
171    pub theme: &'a ThemeColors,
172    pub is_focused: bool,
173    pub panel_index: usize,
174    pub terminal_width: u16,
175    pub terminal_height: u16,
176}
177
178/// A rectangular, focusable unit of UI.
179///
180/// Implementors provide five methods; everything else has a default. Panels
181/// communicate outward by returning events, never by mutating shared state.
182pub trait Panel: Any {
183    /// Stable type name, used for registry lookup and session records.
184    fn name(&self) -> &'static str;
185
186    /// Dynamic title shown in the panel header.
187    fn title(&self) -> String;
188
189    fn render(&mut self, area: Rect, buf: &mut Buffer, ctx: &RenderContext);
190
191    fn handle_key(&mut self, chord: KeyChord) -> Vec<PanelEvent>;
192
193    fn as_any(&self) -> &dyn Any;
194    fn as_any_mut(&mut self) -> &mut dyn Any;
195
196    /// `panel_area` is supplied so the panel can translate to local coordinates.
197    fn handle_mouse(&mut self, event: MouseEvent, panel_area: Rect) -> Vec<PanelEvent> {
198        let _ = (event, panel_area);
199        Vec::new()
200    }
201
202    /// Coalesced scroll. Positive is down.
203    fn handle_scroll(&mut self, delta: i32, panel_area: Rect) -> Vec<PanelEvent> {
204        let _ = (delta, panel_area);
205        Vec::new()
206    }
207
208    /// Where the terminal cursor belongs, in screen coordinates, when this
209    /// panel holds focus. `None` hides it.
210    ///
211    /// The app draws the real terminal cursor rather than a styled cell, so it
212    /// blinks and reshapes the way every other terminal program's does. A panel
213    /// with nothing to edit — a file tree, a viewer — leaves this defaulted.
214    fn cursor_position(&self, panel_area: Rect) -> Option<(u16, u16)> {
215        let _ = panel_area;
216        None
217    }
218
219    /// Perform a named action.
220    ///
221    /// This is the only way a binding, the command palette, or the vim layer
222    /// reaches a panel's behavior.
223    ///
224    /// `None` means "I do not handle this action" and lets the app try it.
225    /// `Some(vec![])` means "handled, nothing to report" — a real outcome, as
226    /// when adding a cursor at the edge of the document does nothing. Folding
227    /// those two answers into an empty vector reads fine today and becomes a
228    /// silent bug the first time an action needs both a panel implementation
229    /// and an app fallback.
230    fn apply_action(&mut self, action: crate::Action) -> Option<Vec<PanelEvent>> {
231        let _ = action;
232        None
233    }
234
235    /// Periodic hook for background work.
236    fn tick(&mut self) -> Vec<PanelEvent> {
237        Vec::new()
238    }
239
240    /// True when the panel consumes Escape itself (e.g. an open search box).
241    fn captures_escape(&self) -> bool {
242        false
243    }
244
245    /// `Some(message)` blocks closing until confirmed.
246    fn needs_close_confirmation(&self) -> Option<String> {
247        None
248    }
249}