Skip to content

Commit d96832b

Browse files
echobtfactorydroid
andauthored
feat(tui): add cortex-tui-components crate for standardized UI components (#467)
Create a new component library crate that provides reusable, high-level TUI components built on top of cortex-core's theme system. ## Key Features - **Component Trait System**: Unified interface for all interactive components with render(), handle_key(), focus_state(), and key_hints() - **20+ Reusable Modules**: - borders: 4 border styles (Rounded, Single, Double, ASCII) - focus: FocusManager for tab navigation - scroll: ScrollState and scrollbar rendering - key_hints: KeyHintsBar for keyboard shortcuts - text: StyledText builder and TextStyle presets - input: TextInput with grapheme-aware cursor - selector: Full-featured selection list with search - form: 5 field types (Text, Secret, Number, Toggle, Select) - modal: ModalStack for nested dialogs - dropdown, checkbox, radio, list, toast, spinner, panel, popup, card - **Theme Integration**: Uses cortex-core's Ocean/Cyan theme constants - **73 Unit Tests**: Comprehensive test coverage This library is designed to reduce raw ratatui usage and code duplication across the TUI, making it easier to build consistent interfaces. Co-authored-by: Droid Agent <droid@factory.ai>
1 parent 0835958 commit d96832b

File tree

23 files changed

+5745
-0
lines changed

23 files changed

+5745
-0
lines changed

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ members = [
8686
"cortex-core",
8787
"cortex-tui",
8888
"cortex-tui-capture",
89+
"cortex-tui-components",
8990

9091
# CLI - Auto-Update
9192
"cortex-update",
@@ -203,6 +204,7 @@ cortex-storage = { path = "cortex-storage" }
203204
cortex-core = { path = "cortex-core" }
204205
cortex-tui = { path = "cortex-tui" }
205206
cortex-tui-capture = { path = "cortex-tui-capture" }
207+
cortex-tui-components = { path = "cortex-tui-components" }
206208
cortex-update = { path = "cortex-update" }
207209
cortex-lsp = { path = "cortex-lsp" }
208210
cortex-snapshot = { path = "cortex-snapshot" }

cortex-tui-components/Cargo.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[package]
2+
name = "cortex-tui-components"
3+
description = "Reusable TUI component library for Cortex - standardized widgets, dropdowns, modals, and forms"
4+
version.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
authors.workspace = true
9+
10+
[dependencies]
11+
# Cortex TUI core (theme, colors, base widgets)
12+
cortex-core = { path = "../cortex-core" }
13+
14+
# TUI framework
15+
ratatui = { workspace = true }
16+
crossterm = { workspace = true }
17+
18+
# Utilities
19+
unicode-width = { workspace = true }
20+
unicode-segmentation = { workspace = true }
21+
22+
# Serialization (for component state)
23+
serde = { workspace = true }
24+
25+
[dev-dependencies]
26+
tokio-test = { workspace = true }
27+
28+
[lints]
29+
workspace = true
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
//! Border styles and utilities.
2+
//!
3+
//! Provides consistent border rendering across all components.
4+
5+
use cortex_core::style::{BORDER, BORDER_FOCUS, CYAN_PRIMARY};
6+
use ratatui::buffer::Buffer;
7+
use ratatui::layout::Rect;
8+
use ratatui::style::Style;
9+
use ratatui::symbols::border::Set as BorderSet;
10+
use ratatui::widgets::{Block, Borders, Widget};
11+
12+
/// Rounded border character set used throughout Cortex TUI.
13+
pub const ROUNDED_BORDER: BorderSet = BorderSet {
14+
top_left: "╭",
15+
top_right: "╮",
16+
bottom_left: "╰",
17+
bottom_right: "╯",
18+
horizontal_top: "─",
19+
horizontal_bottom: "─",
20+
vertical_left: "│",
21+
vertical_right: "│",
22+
};
23+
24+
/// Single-line border character set.
25+
pub const SINGLE_BORDER: BorderSet = BorderSet {
26+
top_left: "┌",
27+
top_right: "┐",
28+
bottom_left: "└",
29+
bottom_right: "┘",
30+
horizontal_top: "─",
31+
horizontal_bottom: "─",
32+
vertical_left: "│",
33+
vertical_right: "│",
34+
};
35+
36+
/// Double-line border character set.
37+
pub const DOUBLE_BORDER: BorderSet = BorderSet {
38+
top_left: "╔",
39+
top_right: "╗",
40+
bottom_left: "╚",
41+
bottom_right: "╝",
42+
horizontal_top: "═",
43+
horizontal_bottom: "═",
44+
vertical_left: "║",
45+
vertical_right: "║",
46+
};
47+
48+
/// ASCII-only border for maximum compatibility.
49+
pub const ASCII_BORDER: BorderSet = BorderSet {
50+
top_left: "+",
51+
top_right: "+",
52+
bottom_left: "+",
53+
bottom_right: "+",
54+
horizontal_top: "-",
55+
horizontal_bottom: "-",
56+
vertical_left: "|",
57+
vertical_right: "|",
58+
};
59+
60+
/// Border style variants.
61+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62+
pub enum BorderStyle {
63+
/// No border
64+
None,
65+
/// Rounded corners (default Cortex style)
66+
#[default]
67+
Rounded,
68+
/// Single line border
69+
Single,
70+
/// Double line border
71+
Double,
72+
/// ASCII-only for maximum terminal compatibility
73+
Ascii,
74+
}
75+
76+
impl BorderStyle {
77+
/// Get the border character set for this style.
78+
pub fn border_set(&self) -> Option<BorderSet> {
79+
match self {
80+
BorderStyle::None => None,
81+
BorderStyle::Rounded => Some(ROUNDED_BORDER),
82+
BorderStyle::Single => Some(SINGLE_BORDER),
83+
BorderStyle::Double => Some(DOUBLE_BORDER),
84+
BorderStyle::Ascii => Some(ASCII_BORDER),
85+
}
86+
}
87+
88+
/// Create a ratatui Block with this border style.
89+
pub fn block(&self, focused: bool) -> Block<'static> {
90+
let border_color = if focused { BORDER_FOCUS } else { BORDER };
91+
92+
let mut block = Block::default().border_style(Style::default().fg(border_color));
93+
94+
if let Some(set) = self.border_set() {
95+
block = block.borders(Borders::ALL).border_set(set);
96+
}
97+
98+
block
99+
}
100+
}
101+
102+
/// A pre-configured rounded border widget.
103+
///
104+
/// Use this for consistent bordered containers across the TUI.
105+
#[derive(Clone)]
106+
pub struct RoundedBorder<'a> {
107+
title: Option<&'a str>,
108+
focused: bool,
109+
border_style: BorderStyle,
110+
}
111+
112+
impl<'a> RoundedBorder<'a> {
113+
/// Create a new rounded border.
114+
pub fn new() -> Self {
115+
Self {
116+
title: None,
117+
focused: false,
118+
border_style: BorderStyle::Rounded,
119+
}
120+
}
121+
122+
/// Set the border title.
123+
pub fn title(mut self, title: &'a str) -> Self {
124+
self.title = Some(title);
125+
self
126+
}
127+
128+
/// Set the focused state.
129+
pub fn focused(mut self, focused: bool) -> Self {
130+
self.focused = focused;
131+
self
132+
}
133+
134+
/// Set the border style.
135+
pub fn style(mut self, style: BorderStyle) -> Self {
136+
self.border_style = style;
137+
self
138+
}
139+
140+
/// Create a ratatui Block from this configuration.
141+
pub fn to_block(&self) -> Block<'static> {
142+
let border_color = if self.focused { BORDER_FOCUS } else { BORDER };
143+
let title_color = if self.focused { CYAN_PRIMARY } else { BORDER };
144+
145+
let mut block = Block::default()
146+
.borders(Borders::ALL)
147+
.border_set(self.border_style.border_set().unwrap_or(ROUNDED_BORDER))
148+
.border_style(Style::default().fg(border_color));
149+
150+
if let Some(title) = self.title {
151+
block = block
152+
.title(format!(" {} ", title))
153+
.title_style(Style::default().fg(title_color));
154+
}
155+
156+
block
157+
}
158+
159+
/// Calculate the inner area after accounting for borders.
160+
pub fn inner(&self, area: Rect) -> Rect {
161+
self.to_block().inner(area)
162+
}
163+
}
164+
165+
impl Default for RoundedBorder<'_> {
166+
fn default() -> Self {
167+
Self::new()
168+
}
169+
}
170+
171+
impl Widget for RoundedBorder<'_> {
172+
fn render(self, area: Rect, buf: &mut Buffer) {
173+
self.to_block().render(area, buf);
174+
}
175+
}
176+
177+
/// Draw a horizontal separator line.
178+
///
179+
/// # Arguments
180+
/// * `buf` - The buffer to render to
181+
/// * `y` - The y coordinate
182+
/// * `x_start` - Starting x coordinate
183+
/// * `width` - Width of the line
184+
/// * `style` - Style to use for the separator
185+
pub fn draw_horizontal_separator(buf: &mut Buffer, y: u16, x_start: u16, width: u16, style: Style) {
186+
for x in x_start..x_start.saturating_add(width) {
187+
if let Some(cell) = buf.cell_mut((x, y)) {
188+
cell.set_char('─').set_style(style);
189+
}
190+
}
191+
}
192+
193+
/// Draw a vertical separator line.
194+
///
195+
/// # Arguments
196+
/// * `buf` - The buffer to render to
197+
/// * `x` - The x coordinate
198+
/// * `y_start` - Starting y coordinate
199+
/// * `height` - Height of the line
200+
/// * `style` - Style to use for the separator
201+
pub fn draw_vertical_separator(buf: &mut Buffer, x: u16, y_start: u16, height: u16, style: Style) {
202+
for y in y_start..y_start.saturating_add(height) {
203+
if let Some(cell) = buf.cell_mut((x, y)) {
204+
cell.set_char('│').set_style(style);
205+
}
206+
}
207+
}
208+
209+
#[cfg(test)]
210+
mod tests {
211+
use super::*;
212+
213+
#[test]
214+
fn test_border_style_set() {
215+
assert!(BorderStyle::None.border_set().is_none());
216+
assert!(BorderStyle::Rounded.border_set().is_some());
217+
assert!(BorderStyle::Single.border_set().is_some());
218+
assert!(BorderStyle::Double.border_set().is_some());
219+
assert!(BorderStyle::Ascii.border_set().is_some());
220+
}
221+
222+
#[test]
223+
fn test_rounded_border_builder() {
224+
let border = RoundedBorder::new()
225+
.title("Test")
226+
.focused(true)
227+
.style(BorderStyle::Single);
228+
229+
assert_eq!(border.title, Some("Test"));
230+
assert!(border.focused);
231+
assert_eq!(border.border_style, BorderStyle::Single);
232+
}
233+
234+
#[test]
235+
fn test_rounded_border_inner() {
236+
let border = RoundedBorder::new();
237+
let area = Rect::new(0, 0, 10, 5);
238+
let inner = border.inner(area);
239+
240+
// Inner area should be smaller by 1 on each side for borders
241+
assert_eq!(inner.x, 1);
242+
assert_eq!(inner.y, 1);
243+
assert_eq!(inner.width, 8);
244+
assert_eq!(inner.height, 3);
245+
}
246+
}

0 commit comments

Comments
 (0)