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}