feat(tui): improve popup layout and rendering for model selector and theme browser

- Add robust size calculations with configurable width bounds and height clamping.
- Guard against zero‑size areas and empty model/theme lists.
- Render popups centered with dynamic positioning, preventing negative Y coordinates.
- Introduce multi‑line list items, badges, and metadata display for models.
- Add ellipsis helper for long descriptions and separate title/metadata generation.
- Refactor theme selector to show current theme, built‑in/custom indicators, and a centered footer.
- Update highlight styles and selection handling for both popups.
This commit is contained in:
2025-10-13 23:23:41 +02:00
parent 6923ee439f
commit 44a00619b5

View File

@@ -2655,81 +2655,183 @@ fn model_has_feature(model: &ModelInfo, keywords: &[&str]) -> bool {
fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let area = centered_rect(60, 60, frame.area());
frame.render_widget(Clear, area);
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
let items: Vec<ListItem> = app
.model_selector_items()
.iter()
.map(|item| match item.kind() {
let selector_items = app.model_selector_items();
if selector_items.is_empty() {
return;
}
let max_width: u16 = 80;
let min_width: u16 = 50;
let mut width = area.width.min(max_width);
if area.width >= min_width {
width = width.max(min_width);
}
width = width.max(1);
let mut height = (selector_items.len().clamp(1, 10) as u16) * 3 + 6;
height = height.clamp(6, area.height);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let mut y = area.y + (area.height.saturating_sub(height)) / 3;
if y < area.y {
y = area.y;
}
let popup_area = Rect::new(x, y, width, height);
frame.render_widget(Clear, popup_area);
let title_line = Line::from(vec![
Span::styled(
" Model Selector ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("· Provider: {}", app.selected_provider),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
),
]);
let block = Block::default()
.title(title_line)
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.info))
.style(Style::default().bg(theme.background).fg(theme.text));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
if inner.width == 0 || inner.height == 0 {
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(4), Constraint::Length(2)])
.split(inner);
let active_model_id = app.selected_model();
let mut items: Vec<ListItem> = Vec::new();
for item in selector_items.iter() {
match item.kind() {
ModelSelectorItemKind::Header { provider, expanded } => {
let marker = if *expanded { "" } else { "" };
let label = format!("{} {}", marker, provider);
ListItem::new(Span::styled(
label,
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
let lines = vec![Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
provider.clone(),
Style::default()
.fg(theme.mode_command)
.add_modifier(Modifier::BOLD),
),
])];
items.push(ListItem::new(lines).style(Style::default().bg(theme.background)));
}
ModelSelectorItemKind::Model { model_index, .. } => {
let mut lines = Vec::new();
if let Some(model) = app.model_info_by_index(*model_index) {
let badges = model_badge_icons(model);
let detail = app.cached_model_detail(&model.id);
let label = build_model_selector_label(model, detail, &badges);
ListItem::new(Span::styled(
label,
Style::default()
.fg(theme.user_message_role)
.add_modifier(Modifier::BOLD),
))
let (title, metadata) = build_model_selector_label(
model,
detail,
&badges,
model.id == active_model_id,
);
lines.push(Line::from(Span::styled(
title,
Style::default().fg(theme.text),
)));
if let Some(meta) = metadata {
lines.push(Line::from(Span::styled(
meta,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)));
}
} else {
ListItem::new(Span::styled(
lines.push(Line::from(Span::styled(
" <model unavailable>",
Style::default().fg(theme.error),
))
)));
}
items.push(ListItem::new(lines).style(Style::default().bg(theme.background)));
}
ModelSelectorItemKind::Empty { provider } => ListItem::new(Span::styled(
format!(" (no models configured for {provider})"),
Style::default()
.fg(theme.unfocused_panel_border)
.add_modifier(Modifier::ITALIC),
)),
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(Span::styled(
"Select Model — 🔧 tools • 🧠 thinking • 👁️ vision • 🎧 audio",
ModelSelectorItemKind::Empty { provider } => {
let lines = vec![Line::from(Span::styled(
format!(" (no models configured for {provider})"),
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.highlight_style(
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
.fg(theme.placeholder)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
))];
items.push(ListItem::new(lines).style(Style::default().bg(theme.background)));
}
}
}
let highlight_style = Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD);
let mut state = ListState::default();
state.select(app.selected_model_item());
frame.render_stateful_widget(list, area, &mut state);
let list = List::new(items)
.highlight_style(highlight_style)
.highlight_symbol(" ")
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_stateful_widget(list, layout[0], &mut state);
let footer = Paragraph::new(Line::from(Span::styled(
"Enter: select · Space: toggle provider · ←/→ collapse/expand · Esc: cancel",
Style::default().fg(theme.placeholder),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(theme.background).fg(theme.placeholder));
frame.render_widget(footer, layout[1]);
}
fn build_model_selector_label(
model: &ModelInfo,
detail: Option<&DetailedModelInfo>,
badges: &[&'static str],
) -> String {
let mut parts = vec![model.id.clone()];
is_current: bool,
) -> (String, Option<String>) {
let mut display_name = if model.name.trim().is_empty() {
model.id.clone()
} else {
model.name.clone()
};
if !display_name.eq_ignore_ascii_case(&model.id) {
display_name.push_str(&format!(" · {}", model.id));
}
let mut title = format!(" {}", display_name);
if !badges.is_empty() {
title.push(' ');
title.push_str(&badges.join(" "));
}
if is_current {
title.push_str("");
}
let mut meta_parts: Vec<String> = Vec::new();
if let Some(detail) = detail {
if let Some(parameters) = detail
.parameter_size
@@ -2737,24 +2839,54 @@ fn build_model_selector_label(
.or(detail.parameters.as_ref())
&& !parameters.trim().is_empty()
{
parts.push(parameters.trim().to_string());
meta_parts.push(parameters.trim().to_string());
}
if let Some(size) = detail.size {
parts.push(format_short_size(size));
meta_parts.push(format_short_size(size));
}
if let Some(ctx) = detail.context_length {
parts.push(format!("ctx {}", ctx));
meta_parts.push(format!("ctx {}", ctx));
}
if let Some(quant) = detail
.quantization
.as_ref()
.filter(|q| !q.trim().is_empty())
{
meta_parts.push(quant.trim().to_string());
}
}
let mut label = format!(" {}", parts.join(""));
if !badges.is_empty() {
label.push(' ');
label.push_str(&badges.join(" "));
if let Some(desc) = model.description.as_deref() {
let trimmed = desc.trim();
if !trimmed.is_empty() {
meta_parts.push(ellipsize(trimmed, 80));
}
}
label
let metadata = if meta_parts.is_empty() {
None
} else {
Some(format!(" {}", meta_parts.join("")))
};
(title, metadata)
}
fn ellipsize(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let target = max_chars.saturating_sub(1).max(1);
let mut truncated = String::new();
for ch in text.chars().take(target) {
truncated.push(ch);
}
truncated.push('…');
truncated
}
fn format_short_size(bytes: u64) -> String {
@@ -3634,123 +3766,142 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) {
fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let area = centered_rect(60, 70, frame.area());
frame.render_widget(Clear, area);
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
let themes = app.available_themes();
let current_theme_name = &app.theme().name;
if themes.is_empty() {
let text = vec![
Line::from(""),
Line::from("No themes available."),
Line::from(""),
Line::from("Press Esc to close."),
];
let max_width: u16 = 80;
let min_width: u16 = 40;
let mut width = area.width.min(max_width);
if area.width >= min_width {
width = width.max(min_width);
} else {
width = area.width;
}
width = width.max(1);
let paragraph = Paragraph::new(text)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Span::styled(
" Themes ",
Style::default()
.fg(theme.mode_help)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.mode_help))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.alignment(Alignment::Center);
let visible_rows = themes.len().clamp(1, 12) as u16;
let mut height = visible_rows.saturating_mul(2).saturating_add(6);
height = height.clamp(6, area.height);
frame.render_widget(paragraph, area);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let mut y = area.y + (area.height.saturating_sub(height)) / 3;
if y < area.y {
y = area.y;
}
let popup_area = Rect::new(x, y, width, height);
frame.render_widget(Clear, popup_area);
let title_line = Line::from(vec![
Span::styled(
" Theme Selector ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("· Current: {}", current_theme_name),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
),
]);
let block = Block::default()
.title(title_line)
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.info))
.style(Style::default().bg(theme.background).fg(theme.text));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
if inner.width == 0 || inner.height == 0 {
return;
}
// Get theme metadata to show built-in vs custom
let all_themes = owlen_core::theme::load_all_themes();
let built_in = owlen_core::theme::built_in_themes();
let items: Vec<ListItem> = themes
.iter()
.enumerate()
.map(|(idx, theme_name)| {
let is_current = theme_name == current_theme_name;
let is_selected = idx == app.selected_theme_index();
let is_built_in = built_in.contains_key(theme_name);
// Build display name
let mut display = theme_name.clone();
if is_current {
display.push_str("");
}
let type_indicator = if is_built_in { "built-in" } else { "custom" };
let name_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let info_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
} else {
Style::default().fg(theme.placeholder)
};
// Try to get theme description or show type
let info_text = if all_themes.contains_key(theme_name) {
format!(" {} · {}", type_indicator, theme_name)
} else {
format!(" {}", type_indicator)
};
let lines = vec![
Line::from(Span::styled(display, name_style)),
Line::from(Span::styled(info_text, info_style)),
];
ListItem::new(lines)
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
format!(" Themes ({}) ", themes.len()),
Style::default()
.fg(theme.mode_help)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.mode_help))
.style(Style::default().bg(theme.background).fg(theme.text)),
);
let footer = Paragraph::new(vec![
Line::from(""),
Line::from("↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc: Cancel"),
])
.alignment(Alignment::Center)
.style(Style::default().fg(theme.placeholder).bg(theme.background));
if themes.is_empty() {
let empty = Paragraph::new(Line::from(Span::styled(
"No themes available · Press Esc to close",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(theme.background).fg(theme.placeholder));
frame.render_widget(empty, inner);
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)])
.split(area);
.constraints([Constraint::Min(4), Constraint::Length(2)])
.split(inner);
let built_in = owlen_core::theme::built_in_themes();
let mut items: Vec<ListItem> = Vec::with_capacity(themes.len());
for theme_name in themes.iter() {
let is_current = theme_name == current_theme_name;
let descriptor = if built_in.contains_key(theme_name) {
"Built-in theme"
} else {
"Custom theme"
};
let mut title = format!(" {}", theme_name);
if is_current {
title.push_str("");
}
let mut title_style = Style::default().fg(theme.text);
if is_current {
title_style = title_style
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD);
}
let metadata_style = Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM);
let lines = vec![
Line::from(Span::styled(title, title_style)),
Line::from(vec![
Span::raw(" "),
Span::styled(descriptor, metadata_style),
]),
];
items.push(ListItem::new(lines).style(Style::default().bg(theme.background)));
}
let highlight_style = Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD);
let mut state = ListState::default();
let selected_index = app
.selected_theme_index()
.min(themes.len().saturating_sub(1));
state.select(Some(selected_index));
let list = List::new(items)
.highlight_style(highlight_style)
.highlight_symbol(" ")
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_stateful_widget(list, layout[0], &mut state);
let footer = Paragraph::new(Line::from(Span::styled(
"↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc: Cancel",
Style::default().fg(theme.placeholder),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(theme.background).fg(theme.placeholder));
frame.render_widget(list, layout[0]);
frame.render_widget(footer, layout[1]);
}