From 44a00619b51158ad0c025a4912b61039a5fb00f0 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 13 Oct 2025 23:23:41 +0200 Subject: [PATCH] feat(tui): improve popup layout and rendering for model selector and theme browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- crates/owlen-tui/src/ui.rs | 477 ++++++++++++++++++++++++------------- 1 file changed, 314 insertions(+), 163 deletions(-) diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index c5b259e..def12df 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -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 = 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 = 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( " ", 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) { + 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 = 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 = 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 = 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]); }