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:
2025-10-24 02:54:19 +02:00
parent bbb94367e1
commit 3f6d7d56f6
10 changed files with 1397 additions and 365 deletions

View File

@@ -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);
}

View 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)
}
}
}

View File

@@ -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

View File

@@ -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]);
}