1use std::time::Duration;
15
16use omp_core::{Str, fmts};
17use omp_tui::{
18 Color, Dim, Key, Layer, Mouse, OverlayAnchor, OverlayOptions, Prop, Size, Ui, UiContext,
19 UiEvent, dom,
20};
21
22const CYAN: Color = Color::Rgb(62, 190, 203);
23const GREEN: Color = Color::Rgb(81, 196, 112);
24const DIM: Color = Color::Rgb(110, 116, 124);
25
26const WIDTH: u16 = 30;
28const MIN_VIEWPORT: Size = Size::new(96, 20);
31
32const FILES: [(&str, &str); 5] = [
35 ("renderer.rs", "+142"),
36 ("frame.rs", "+38"),
37 ("seam.rs", "+210"),
38 ("commit_tests.rs", "+96"),
39 ("seams.md", "+17"),
40];
41
42pub struct Sidebar {
44 ui: Ui,
45 options: OverlayOptions,
46 open: bool,
48 focused: bool,
50 elapsed_seconds: u64,
51 height: u16,
52}
53
54impl Sidebar {
55 pub fn new(model: &str, ctx: &UiContext) -> Self {
57 let options = OverlayOptions::default()
58 .anchor(OverlayAnchor::Right)
59 .width(Dim::Cells(WIDTH))
60 .non_modal()
61 .min_viewport(MIN_VIEWPORT);
62 let mut ui = build(model, ctx);
63 ui.blur();
66 Self { ui, options, open: true, focused: false, elapsed_seconds: 0, height: 0 }
67 }
68
69 const fn visible(&self, viewport: Size) -> bool {
71 self.open && viewport.width >= MIN_VIEWPORT.width && viewport.height >= MIN_VIEWPORT.height
72 }
73
74 pub const fn reserved(&self, viewport: Size) -> u16 {
78 if self.visible(viewport) { WIDTH } else { 0 }
79 }
80
81 pub const fn focused(&self) -> bool {
83 self.focused
84 }
85
86 pub fn toggle(&mut self) {
88 self.open = !self.open;
89 if self.open {
90 self.focused = true;
91 self.ui.focus_first();
92 } else {
93 self.blur();
94 }
95 }
96
97 pub fn handle_key(&mut self, key: Key) {
99 if self.ui.handle_key(key) == UiEvent::Cancel {
100 self.blur();
101 }
102 }
103
104 pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
108 if !self.open {
109 return false;
110 }
111 if self
112 .ui
113 .handle_mouse_as_layer(&self.options, viewport, col, row, kind)
114 .is_some()
115 {
116 if kind == Mouse::Click && !self.focused {
117 self.focused = true;
118 self.ui.focus_first();
119 }
120 true
121 } else {
122 if kind == Mouse::Click {
123 self.blur();
124 }
125 false
126 }
127 }
128
129 pub fn set_model(&mut self, name: &str) {
131 self.ui.set_text("model", name);
132 }
133
134 pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139 if !self.visible(viewport) {
140 if self.focused {
141 self.blur();
142 }
143 return None;
144 }
145 if self.height != viewport.height {
146 self.height = viewport.height;
147 self.ui.set_prop("rail", Prop::H, viewport.height);
148 self.ui.set_prop("body", Prop::H, viewport.height);
149 }
150 let seconds = elapsed.as_secs();
151 if seconds != self.elapsed_seconds {
152 self.elapsed_seconds = seconds;
153 self.ui.set_text("elapsed", elapsed_label(seconds));
154 }
155 Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156 }
157
158 fn blur(&mut self) {
159 self.focused = false;
160 self.ui.blur();
161 }
162}
163
164fn elapsed_label(seconds: u64) -> Str {
165 fmts!("{}:{:02}", seconds / 60, seconds % 60)
166}
167
168fn build(model: &str, ctx: &UiContext) -> Ui {
171 let files = FILES;
172 Ui::from_root(
173 dom! {
174 <row id="rail" h=24>
175 <hr/>
176 <col id="body" h=24 grow pad="0 1" gap=1>
177 <text bold fg={CYAN}>{"session"}</text>
178 <col>
179 <row gap=1>
180 <text fg={DIM} w=8>{"model"}</text>
181 <text id="model" truncate>{model}</text>
182 </row>
183 <row gap=1>
184 <text fg={DIM} w=8>{"elapsed"}</text>
185 <text id="elapsed">{"0:00"}</text>
186 </row>
187 <row gap=1>
188 <text fg={DIM} w=8>{"branch"}</text>
189 <text truncate>{"tui/seam-commits"}</text>
190 </row>
191 </col>
192 <hr/>
193 <text bold fg={CYAN}>{"files"}</text>
194 <select id="files" h={files.len() as u16}>
195 for (name, delta) in files {
196 <option value={name} label={name}>
197 <td grow truncate><pre>{name}</pre></td>
198 <td align=end><pre fg={GREEN}>{delta}</pre></td>
199 </option>
200 }
201 </select>
202 <spacer grow/>
203 <text dim truncate>{"ctrl+b rail · esc back"}</text>
204 </col>
205 </row>
206 },
207 WIDTH,
208 ctx.clone(),
209 )
210}
211
212#[cfg(test)]
213mod tests {
214 use std::time::Duration;
215
216 use omp_tui::{Size, UiContext, test_support::frame_row_text};
217
218 use super::Sidebar;
219
220 #[test]
221 fn rail_starts_passive_without_focus_chrome_or_caret() {
222 let ctx = UiContext::default();
223 let viewport = Size::new(120, 30);
224 let mut sidebar = Sidebar::new("Claude Fable 5", &ctx);
225
226 let passive: Vec<String> = {
227 let layer = sidebar
228 .layer(viewport, Duration::ZERO)
229 .expect("the rail opens by default");
230 assert!(!layer.active, "a passive rail never owns the caret");
231 (0..30)
232 .map(|row| frame_row_text(layer.frame, row))
233 .collect()
234 };
235
236 sidebar.toggle(); sidebar.toggle(); let layer = sidebar
239 .layer(viewport, Duration::ZERO)
240 .expect("the rail reopened");
241 assert!(layer.active, "the toggled-open rail owns the keyboard");
242 let focused: Vec<String> = (0..30)
243 .map(|row| frame_row_text(layer.frame, row))
244 .collect();
245 assert_ne!(passive, focused, "taking the keyboard adds the focus chrome");
246 }
247}