use std::time::Duration;
use omp_core::{Str, fmts};
use omp_tui::{
Color, Dim, Key, Layer, Mouse, OverlayAnchor, OverlayOptions, Prop, Size, Ui, UiContext,
UiEvent, dom,
};
const CYAN: Color = Color::Rgb(62, 190, 203);
const GREEN: Color = Color::Rgb(81, 196, 112);
const DIM: Color = Color::Rgb(110, 116, 124);
const WIDTH: u16 = 30;
const MIN_VIEWPORT: Size = Size::new(96, 20);
const FILES: [(&str, &str); 5] = [
("renderer.rs", "+142"),
("frame.rs", "+38"),
("seam.rs", "+210"),
("commit_tests.rs", "+96"),
("seams.md", "+17"),
];
pub struct Sidebar {
ui: Ui,
options: OverlayOptions,
open: bool,
focused: bool,
elapsed_seconds: u64,
height: u16,
}
impl Sidebar {
pub fn new(model: &str, ctx: &UiContext) -> Self {
let options = OverlayOptions::default()
.anchor(OverlayAnchor::Right)
.width(Dim::Cells(WIDTH))
.non_modal()
.min_viewport(MIN_VIEWPORT);
let mut ui = build(model, ctx);
ui.blur();
Self { ui, options, open: true, focused: false, elapsed_seconds: 0, height: 0 }
}
const fn visible(&self, viewport: Size) -> bool {
self.open && viewport.width >= MIN_VIEWPORT.width && viewport.height >= MIN_VIEWPORT.height
}
pub const fn reserved(&self, viewport: Size) -> u16 {
if self.visible(viewport) { WIDTH } else { 0 }
}
pub const fn focused(&self) -> bool {
self.focused
}
pub fn toggle(&mut self) {
self.open = !self.open;
if self.open {
self.focused = true;
self.ui.focus_first();
} else {
self.blur();
}
}
pub fn handle_key(&mut self, key: Key) {
if self.ui.handle_key(key) == UiEvent::Cancel {
self.blur();
}
}
pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
if !self.open {
return false;
}
if self
.ui
.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
.is_some()
{
if kind == Mouse::Click && !self.focused {
self.focused = true;
self.ui.focus_first();
}
true
} else {
if kind == Mouse::Click {
self.blur();
}
false
}
}
pub fn set_model(&mut self, name: &str) {
self.ui.set_text("model", name);
}
pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
if !self.visible(viewport) {
if self.focused {
self.blur();
}
return None;
}
if self.height != viewport.height {
self.height = viewport.height;
self.ui.set_prop("rail", Prop::H, viewport.height);
self.ui.set_prop("body", Prop::H, viewport.height);
}
let seconds = elapsed.as_secs();
if seconds != self.elapsed_seconds {
self.elapsed_seconds = seconds;
self.ui.set_text("elapsed", elapsed_label(seconds));
}
Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
}
fn blur(&mut self) {
self.focused = false;
self.ui.blur();
}
}
fn elapsed_label(seconds: u64) -> Str {
fmts!("{}:{:02}", seconds / 60, seconds % 60)
}
fn build(model: &str, ctx: &UiContext) -> Ui {
let files = FILES;
Ui::from_root(
dom! {
<row id="rail" h=24>
<hr/>
<col id="body" h=24 grow pad="0 1" gap=1>
<text bold fg={CYAN}>{"session"}</text>
<col>
<row gap=1>
<text fg={DIM} w=8>{"model"}</text>
<text id="model" truncate>{model}</text>
</row>
<row gap=1>
<text fg={DIM} w=8>{"elapsed"}</text>
<text id="elapsed">{"0:00"}</text>
</row>
<row gap=1>
<text fg={DIM} w=8>{"branch"}</text>
<text truncate>{"tui/seam-commits"}</text>
</row>
</col>
<hr/>
<text bold fg={CYAN}>{"files"}</text>
<select id="files" h={files.len() as u16}>
for (name, delta) in files {
<option value={name} label={name}>
<td grow truncate><pre>{name}</pre></td>
<td align=end><pre fg={GREEN}>{delta}</pre></td>
</option>
}
</select>
<spacer grow/>
<text dim truncate>{"ctrl+b rail · esc back"}</text>
</col>
</row>
},
WIDTH,
ctx.clone(),
)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use omp_tui::{Size, UiContext, test_support::frame_row_text};
use super::Sidebar;
#[test]
fn rail_starts_passive_without_focus_chrome_or_caret() {
let ctx = UiContext::default();
let viewport = Size::new(120, 30);
let mut sidebar = Sidebar::new("Claude Fable 5", &ctx);
let passive: Vec<String> = {
let layer = sidebar
.layer(viewport, Duration::ZERO)
.expect("the rail opens by default");
assert!(!layer.active, "a passive rail never owns the caret");
(0..30)
.map(|row| frame_row_text(layer.frame, row))
.collect()
};
sidebar.toggle(); sidebar.toggle(); let layer = sidebar
.layer(viewport, Duration::ZERO)
.expect("the rail reopened");
assert!(layer.active, "the toggled-open rail owns the keyboard");
let focused: Vec<String> = (0..30)
.map(|row| frame_row_text(layer.frame, row))
.collect();
assert_ne!(passive, focused, "taking the keyboard adds the focus chrome");
}
}