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) { fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme(); let theme = app.theme();
let area = centered_rect(60, 60, frame.area()); let area = frame.area();
frame.render_widget(Clear, area); if area.width == 0 || area.height == 0 {
return;
}
let items: Vec<ListItem> = app let selector_items = app.model_selector_items();
.model_selector_items() if selector_items.is_empty() {
.iter() return;
.map(|item| match item.kind() { }
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 } => { ModelSelectorItemKind::Header { provider, expanded } => {
let marker = if *expanded { "" } else { "" }; let marker = if *expanded { "" } else { "" };
let label = format!("{} {}", marker, provider); let lines = vec![Line::from(vec![
ListItem::new(Span::styled( Span::styled(
label, marker,
Style::default() Style::default()
.fg(theme.focused_panel_border) .fg(theme.placeholder)
.add_modifier(Modifier::BOLD), .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, .. } => { ModelSelectorItemKind::Model { model_index, .. } => {
let mut lines = Vec::new();
if let Some(model) = app.model_info_by_index(*model_index) { if let Some(model) = app.model_info_by_index(*model_index) {
let badges = model_badge_icons(model); let badges = model_badge_icons(model);
let detail = app.cached_model_detail(&model.id); let detail = app.cached_model_detail(&model.id);
let label = build_model_selector_label(model, detail, &badges); let (title, metadata) = build_model_selector_label(
ListItem::new(Span::styled( model,
label, 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() Style::default()
.fg(theme.user_message_role) .fg(theme.placeholder)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::DIM),
)) )));
}
} else { } else {
ListItem::new(Span::styled( lines.push(Line::from(Span::styled(
" <model unavailable>", " <model unavailable>",
Style::default().fg(theme.error), Style::default().fg(theme.error),
)) )));
} }
items.push(ListItem::new(lines).style(Style::default().bg(theme.background)));
} }
ModelSelectorItemKind::Empty { provider } => ListItem::new(Span::styled( ModelSelectorItemKind::Empty { provider } => {
let lines = vec![Line::from(Span::styled(
format!(" (no models configured for {provider})"), format!(" (no models configured for {provider})"),
Style::default() Style::default()
.fg(theme.unfocused_panel_border) .fg(theme.placeholder)
.add_modifier(Modifier::ITALIC), .add_modifier(Modifier::DIM | Modifier::ITALIC),
)), ))];
}) items.push(ListItem::new(lines).style(Style::default().bg(theme.background)));
.collect(); }
}
}
let list = List::new(items) let highlight_style = Style::default()
.block( .bg(theme.selection_bg)
Block::default() .fg(theme.selection_fg)
.title(Span::styled( .add_modifier(Modifier::BOLD);
"Select Model — 🔧 tools • 🧠 thinking • 👁️ vision • 🎧 audio",
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("");
let mut state = ListState::default(); let mut state = ListState::default();
state.select(app.selected_model_item()); 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( fn build_model_selector_label(
model: &ModelInfo, model: &ModelInfo,
detail: Option<&DetailedModelInfo>, detail: Option<&DetailedModelInfo>,
badges: &[&'static str], badges: &[&'static str],
) -> String { is_current: bool,
let mut parts = vec![model.id.clone()]; ) -> (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(detail) = detail {
if let Some(parameters) = detail if let Some(parameters) = detail
.parameter_size .parameter_size
@@ -2737,24 +2839,54 @@ fn build_model_selector_label(
.or(detail.parameters.as_ref()) .or(detail.parameters.as_ref())
&& !parameters.trim().is_empty() && !parameters.trim().is_empty()
{ {
parts.push(parameters.trim().to_string()); meta_parts.push(parameters.trim().to_string());
} }
if let Some(size) = detail.size { 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 { 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 let Some(desc) = model.description.as_deref() {
if !badges.is_empty() { let trimmed = desc.trim();
label.push(' '); if !trimmed.is_empty() {
label.push_str(&badges.join(" ")); 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 { 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) { fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme(); let theme = app.theme();
let area = centered_rect(60, 70, frame.area()); let area = frame.area();
frame.render_widget(Clear, area); if area.width == 0 || area.height == 0 {
return;
}
let themes = app.available_themes(); let themes = app.available_themes();
let current_theme_name = &app.theme().name; let current_theme_name = &app.theme().name;
if themes.is_empty() { let max_width: u16 = 80;
let text = vec![ let min_width: u16 = 40;
Line::from(""), let mut width = area.width.min(max_width);
Line::from("No themes available."), if area.width >= min_width {
Line::from(""), width = width.max(min_width);
Line::from("Press Esc to close."), } else {
]; width = area.width;
}
width = width.max(1);
let paragraph = Paragraph::new(text) let visible_rows = themes.len().clamp(1, 12) as u16;
.style(Style::default().bg(theme.background)) let mut height = visible_rows.saturating_mul(2).saturating_add(6);
.block( height = height.clamp(6, area.height);
Block::default()
.title(Span::styled( let x = area.x + (area.width.saturating_sub(width)) / 2;
" Themes ", 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() Style::default()
.fg(theme.mode_help) .fg(theme.placeholder)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::DIM),
)) ),
.borders(Borders::ALL) ]);
.border_style(Style::default().fg(theme.mode_help))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area); 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; return;
} }
// Get theme metadata to show built-in vs custom if themes.is_empty() {
let all_themes = owlen_core::theme::load_all_themes(); let empty = Paragraph::new(Line::from(Span::styled(
let built_in = owlen_core::theme::built_in_themes(); "No themes available · Press Esc to close",
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() Style::default()
.fg(theme.selection_fg) .fg(theme.placeholder)
.bg(theme.selection_bg) .add_modifier(Modifier::DIM | Modifier::ITALIC),
.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) .alignment(Alignment::Center)
.style(Style::default().fg(theme.placeholder).bg(theme.background)); .style(Style::default().bg(theme.background).fg(theme.placeholder));
frame.render_widget(empty, inner);
return;
}
let layout = Layout::default() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)]) .constraints([Constraint::Min(4), Constraint::Length(2)])
.split(area); .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]); frame.render_widget(footer, layout[1]);
} }