feat(ui): add glass modals and theme preview
AC:\n- Theme, help, command, and model modals share the glass chrome.\n- Theme selector shows a live preview for the highlighted palette.\n- Updated docs and screenshots explain the refreshed cockpit.\n\nTests:\n- cargo test -p owlen-tui
This commit is contained in:
@@ -115,6 +115,7 @@ pub struct ContextUsage {
|
||||
pub(crate) struct LayoutSnapshot {
|
||||
pub(crate) frame: Rect,
|
||||
pub(crate) content: Rect,
|
||||
pub(crate) header_panel: Option<Rect>,
|
||||
pub(crate) file_panel: Option<Rect>,
|
||||
pub(crate) chat_panel: Option<Rect>,
|
||||
pub(crate) thinking_panel: Option<Rect>,
|
||||
@@ -131,6 +132,7 @@ impl LayoutSnapshot {
|
||||
Self {
|
||||
frame,
|
||||
content,
|
||||
header_panel: None,
|
||||
file_panel: None,
|
||||
chat_panel: None,
|
||||
thinking_panel: None,
|
||||
@@ -155,6 +157,11 @@ impl LayoutSnapshot {
|
||||
return Some(UiRegion::ModelInfo);
|
||||
}
|
||||
}
|
||||
if let Some(rect) = self.header_panel {
|
||||
if Self::contains(rect, column, row) {
|
||||
return Some(UiRegion::Header);
|
||||
}
|
||||
}
|
||||
if let Some(rect) = self.code_panel {
|
||||
if Self::contains(rect, column, row) {
|
||||
return Some(UiRegion::Code);
|
||||
@@ -213,6 +220,7 @@ impl Default for LayoutSnapshot {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum UiRegion {
|
||||
Header,
|
||||
Frame,
|
||||
Content,
|
||||
FileTree,
|
||||
@@ -7992,7 +8000,8 @@ impl ChatApp {
|
||||
| UiRegion::Status
|
||||
| UiRegion::Chat
|
||||
| UiRegion::Content
|
||||
| UiRegion::Frame => {
|
||||
| UiRegion::Frame
|
||||
| UiRegion::Header => {
|
||||
if self.focus_panel(FocusedPanel::Chat) {
|
||||
self.auto_scroll
|
||||
.on_user_scroll(amount, self.viewport_height);
|
||||
@@ -8036,7 +8045,8 @@ impl ChatApp {
|
||||
| UiRegion::Status
|
||||
| UiRegion::Chat
|
||||
| UiRegion::Content
|
||||
| UiRegion::Frame => {
|
||||
| UiRegion::Frame
|
||||
| UiRegion::Header => {
|
||||
self.focus_panel(FocusedPanel::Chat);
|
||||
self.set_input_mode(InputMode::Normal);
|
||||
}
|
||||
|
||||
144
crates/owlen-tui/src/glass.rs
Normal file
144
crates/owlen-tui/src/glass.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use owlen_core::theme::Theme;
|
||||
use ratatui::style::Color;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct GlassPalette {
|
||||
pub active: Color,
|
||||
pub inactive: Color,
|
||||
pub highlight: Color,
|
||||
pub track: Color,
|
||||
pub label: Color,
|
||||
pub shadow: Color,
|
||||
pub context_stops: [Color; 3],
|
||||
pub usage_stops: [Color; 3],
|
||||
}
|
||||
|
||||
impl GlassPalette {
|
||||
pub fn for_theme(theme: &Theme) -> Self {
|
||||
let luminance = color_luminance(theme.background);
|
||||
if luminance < 0.5 {
|
||||
Self {
|
||||
active: Color::Rgb(26, 28, 40),
|
||||
inactive: Color::Rgb(18, 20, 30),
|
||||
highlight: Color::Rgb(32, 35, 48),
|
||||
track: Color::Rgb(35, 38, 50),
|
||||
label: Color::Rgb(241, 245, 249),
|
||||
shadow: Color::Rgb(8, 9, 16),
|
||||
context_stops: [
|
||||
Color::Rgb(56, 189, 248),
|
||||
Color::Rgb(250, 204, 21),
|
||||
Color::Rgb(248, 113, 113),
|
||||
],
|
||||
usage_stops: [
|
||||
Color::Rgb(34, 211, 238),
|
||||
Color::Rgb(250, 204, 21),
|
||||
Color::Rgb(248, 113, 113),
|
||||
],
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
active: Color::Rgb(242, 247, 255),
|
||||
inactive: Color::Rgb(229, 235, 250),
|
||||
highlight: Color::Rgb(224, 230, 248),
|
||||
track: Color::Rgb(203, 210, 230),
|
||||
label: Color::Rgb(31, 41, 55),
|
||||
shadow: Color::Rgb(200, 205, 220),
|
||||
context_stops: [
|
||||
Color::Rgb(59, 130, 246),
|
||||
Color::Rgb(234, 179, 8),
|
||||
Color::Rgb(239, 68, 68),
|
||||
],
|
||||
usage_stops: [
|
||||
Color::Rgb(20, 184, 166),
|
||||
Color::Rgb(245, 158, 11),
|
||||
Color::Rgb(239, 68, 68),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gradient_color(stops: &[Color; 3], t: f64) -> Color {
|
||||
let clamped = t.clamp(0.0, 1.0);
|
||||
let segments = stops.len() - 1;
|
||||
let scaled = clamped * segments as f64;
|
||||
let index = scaled.floor() as usize;
|
||||
let frac = scaled - index as f64;
|
||||
let start = stops[index.min(stops.len() - 1)];
|
||||
let end = stops[(index + 1).min(stops.len() - 1)];
|
||||
let (sr, sg, sb) = color_to_rgb(start);
|
||||
let (er, eg, eb) = color_to_rgb(end);
|
||||
let mix = |a: u8, b: u8| -> u8 { (a as f64 + (b as f64 - a as f64) * frac).round() as u8 };
|
||||
Color::Rgb(mix(sr, er), mix(sg, eg), mix(sb, eb))
|
||||
}
|
||||
|
||||
fn color_luminance(color: Color) -> f64 {
|
||||
let (r, g, b) = color_to_rgb(color);
|
||||
let r = r as f64 / 255.0;
|
||||
let g = g as f64 / 255.0;
|
||||
let b = b as f64 / 255.0;
|
||||
0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
fn color_to_rgb(color: Color) -> (u8, u8, u8) {
|
||||
match color {
|
||||
Color::Reset => (0, 0, 0),
|
||||
Color::Black => (0, 0, 0),
|
||||
Color::Red => (205, 49, 49),
|
||||
Color::Green => (49, 205, 49),
|
||||
Color::Yellow => (205, 198, 49),
|
||||
Color::Blue => (49, 49, 205),
|
||||
Color::Magenta => (205, 49, 205),
|
||||
Color::Cyan => (49, 205, 205),
|
||||
Color::Gray => (170, 170, 170),
|
||||
Color::DarkGray => (100, 100, 100),
|
||||
Color::LightRed => (255, 128, 128),
|
||||
Color::LightGreen => (144, 238, 144),
|
||||
Color::LightYellow => (255, 255, 170),
|
||||
Color::LightBlue => (173, 216, 230),
|
||||
Color::LightMagenta => (255, 182, 255),
|
||||
Color::LightCyan => (175, 238, 238),
|
||||
Color::White => (255, 255, 255),
|
||||
Color::Rgb(r, g, b) => (r, g, b),
|
||||
Color::Indexed(idx) => indexed_to_rgb(idx),
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_to_rgb(idx: u8) -> (u8, u8, u8) {
|
||||
match idx {
|
||||
0 => (0, 0, 0),
|
||||
1 => (128, 0, 0),
|
||||
2 => (0, 128, 0),
|
||||
3 => (128, 128, 0),
|
||||
4 => (0, 0, 128),
|
||||
5 => (128, 0, 128),
|
||||
6 => (0, 128, 128),
|
||||
7 => (192, 192, 192),
|
||||
8 => (128, 128, 128),
|
||||
9 => (255, 0, 0),
|
||||
10 => (0, 255, 0),
|
||||
11 => (255, 255, 0),
|
||||
12 => (92, 92, 255),
|
||||
13 => (255, 0, 255),
|
||||
14 => (0, 255, 255),
|
||||
15 => (255, 255, 255),
|
||||
16..=231 => {
|
||||
let idx = idx - 16;
|
||||
let r = idx / 36;
|
||||
let g = (idx % 36) / 6;
|
||||
let b = idx % 6;
|
||||
let convert = |component: u8| {
|
||||
if component == 0 {
|
||||
0
|
||||
} else {
|
||||
component.saturating_mul(40).saturating_add(55)
|
||||
}
|
||||
};
|
||||
(convert(r), convert(g), convert(b))
|
||||
}
|
||||
232..=255 => {
|
||||
let shade = 8 + (idx - 232) * 10;
|
||||
(shade, shade, shade)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub mod code_app;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod events;
|
||||
pub(crate) mod glass;
|
||||
pub mod highlight;
|
||||
pub mod model_info_panel;
|
||||
pub mod slash;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, block::Padding},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
@@ -16,6 +16,7 @@ use crate::chat_app::{
|
||||
ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo,
|
||||
ModelSelectorItemKind,
|
||||
};
|
||||
use crate::glass::GlassPalette;
|
||||
|
||||
/// Filtering modes for the model picker popup.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -29,6 +30,7 @@ pub enum FilterMode {
|
||||
|
||||
pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
let theme = app.theme();
|
||||
let palette = GlassPalette::for_theme(theme);
|
||||
let area = frame.area();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
@@ -62,17 +64,33 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
}
|
||||
|
||||
let popup_area = Rect::new(x, y, width, height);
|
||||
if popup_area.width > 2 && popup_area.height > 2 {
|
||||
let shadow_area = Rect::new(
|
||||
popup_area.x.saturating_add(1),
|
||||
popup_area.y.saturating_add(1),
|
||||
popup_area.width.saturating_sub(1),
|
||||
popup_area.height.saturating_sub(1),
|
||||
);
|
||||
if shadow_area.width > 0 && shadow_area.height > 0 {
|
||||
frame.render_widget(
|
||||
Block::default().style(Style::default().bg(palette.shadow)),
|
||||
shadow_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
frame.render_widget(Clear, popup_area);
|
||||
|
||||
let mut title_spans = vec![
|
||||
Span::styled(
|
||||
" Model Selector ",
|
||||
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
|
||||
Style::default()
|
||||
.fg(palette.label)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("· Provider: {}", app.selected_provider),
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.fg(palette.label)
|
||||
.add_modifier(Modifier::DIM),
|
||||
),
|
||||
];
|
||||
@@ -83,9 +101,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(title_spans))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.info))
|
||||
.style(Style::default().bg(theme.background).fg(theme.text));
|
||||
.title_style(Style::default().fg(palette.label))
|
||||
.borders(Borders::NONE)
|
||||
.padding(Padding::new(2, 2, 1, 1))
|
||||
.style(Style::default().bg(palette.active).fg(palette.label));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
frame.render_widget(block, popup_area);
|
||||
@@ -104,10 +123,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
|
||||
let matches = app.visible_model_count();
|
||||
let search_prefix = Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.fg(palette.label)
|
||||
.add_modifier(Modifier::DIM);
|
||||
let bracket_style = Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.fg(palette.label)
|
||||
.add_modifier(Modifier::DIM);
|
||||
let caret_style = if search_active {
|
||||
Style::default()
|
||||
@@ -115,7 +134,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.fg(palette.label)
|
||||
.add_modifier(Modifier::DIM)
|
||||
};
|
||||
|
||||
@@ -135,8 +154,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
search_spans.push(Span::styled(
|
||||
"Type to search…",
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM),
|
||||
.fg(palette.label)
|
||||
.add_modifier(Modifier::DIM | Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -153,35 +172,37 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
suffix_label,
|
||||
if matches == 1 { "" } else { "s" }
|
||||
),
|
||||
Style::default().fg(theme.placeholder),
|
||||
Style::default()
|
||||
.fg(palette.label)
|
||||
.add_modifier(Modifier::DIM),
|
||||
));
|
||||
|
||||
let search_line = Line::from(search_spans);
|
||||
|
||||
let instruction_line = if search_active {
|
||||
Line::from(vec![
|
||||
Span::styled("Backspace", Style::default().fg(theme.placeholder)),
|
||||
Span::styled("Backspace", Style::default().fg(palette.label)),
|
||||
Span::raw(": delete "),
|
||||
Span::styled("Ctrl+U", Style::default().fg(theme.placeholder)),
|
||||
Span::styled("Ctrl+U", Style::default().fg(palette.label)),
|
||||
Span::raw(": clear "),
|
||||
Span::styled("Enter", Style::default().fg(theme.placeholder)),
|
||||
Span::styled("Enter", Style::default().fg(palette.label)),
|
||||
Span::raw(": select "),
|
||||
Span::styled("Esc", Style::default().fg(theme.placeholder)),
|
||||
Span::styled("Esc", Style::default().fg(palette.label)),
|
||||
Span::raw(": close"),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled("Enter", Style::default().fg(theme.placeholder)),
|
||||
Span::styled("Enter", Style::default().fg(palette.label)),
|
||||
Span::raw(": select "),
|
||||
Span::styled("Space", Style::default().fg(theme.placeholder)),
|
||||
Span::styled("Space", Style::default().fg(palette.label)),
|
||||
Span::raw(": toggle provider "),
|
||||
Span::styled("Esc", Style::default().fg(theme.placeholder)),
|
||||
Span::styled("Esc", Style::default().fg(palette.label)),
|
||||
Span::raw(": close"),
|
||||
])
|
||||
};
|
||||
|
||||
let search_paragraph = Paragraph::new(vec![search_line, instruction_line])
|
||||
.style(Style::default().bg(theme.background).fg(theme.text));
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label));
|
||||
frame.render_widget(search_paragraph, layout[0]);
|
||||
|
||||
let highlight_style = Style::default()
|
||||
@@ -236,7 +257,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
));
|
||||
|
||||
let line = clip_line_to_width(Line::from(spans), max_line_width);
|
||||
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background)));
|
||||
items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active)));
|
||||
}
|
||||
ModelSelectorItemKind::Scope { label, status, .. } => {
|
||||
let (style, icon) = scope_status_style(*status, theme);
|
||||
@@ -248,7 +269,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
]),
|
||||
max_line_width,
|
||||
);
|
||||
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background)));
|
||||
items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active)));
|
||||
}
|
||||
ModelSelectorItemKind::Model { model_index, .. } => {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
@@ -286,7 +307,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
max_line_width,
|
||||
));
|
||||
}
|
||||
items.push(ListItem::new(lines).style(Style::default().bg(theme.background)));
|
||||
items.push(ListItem::new(lines).style(Style::default().bg(palette.active)));
|
||||
}
|
||||
ModelSelectorItemKind::Empty {
|
||||
message, status, ..
|
||||
@@ -299,7 +320,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
let mut spans = vec![Span::styled(icon, style), Span::raw(" ")];
|
||||
spans.push(Span::styled(format!(" {}", msg), style));
|
||||
let line = clip_line_to_width(Line::from(spans), max_line_width);
|
||||
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background)));
|
||||
items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,7 +332,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
.fg(theme.selection_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(" ");
|
||||
.highlight_symbol(" ")
|
||||
.style(Style::default().bg(palette.active).fg(palette.label));
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(app.selected_model_item());
|
||||
@@ -325,10 +347,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
footer_text,
|
||||
Style::default().fg(theme.placeholder),
|
||||
Style::default().fg(palette.label),
|
||||
)))
|
||||
.alignment(ratatui::layout::Alignment::Center)
|
||||
.style(Style::default().bg(theme.background).fg(theme.placeholder));
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label));
|
||||
frame.render_widget(footer, layout[2]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user