use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem};
use crate::shared::api::local_servers::LocalOffer;
use crate::shared::i18n::Locale;
use crate::shared::theme::Palette;
use crate::shared::ui::{ListScroll, MinSize, legend_width, mark_selected};
use crate::shared::wrap;
#[derive(Debug, Clone, PartialEq)]
pub enum LocalServerAction {
None,
Cancel,
Pick(LocalOffer),
}
pub struct LocalServerPickerState {
offers: Vec<LocalOffer>,
selected: usize,
scroll: ListScroll,
}
impl LocalServerPickerState {
pub fn new(offers: Vec<LocalOffer>) -> Self {
Self {
offers,
selected: 0,
scroll: ListScroll::default(),
}
}
pub fn on_key(&mut self, key: KeyEvent) -> LocalServerAction {
if key.kind != KeyEventKind::Press {
return LocalServerAction::None;
}
match key.code {
KeyCode::Esc => LocalServerAction::Cancel,
KeyCode::Enter => match self.offers.get(self.selected) {
Some(offer) => LocalServerAction::Pick(offer.clone()),
None => LocalServerAction::Cancel,
},
KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
LocalServerAction::None
}
KeyCode::Down => {
if !self.offers.is_empty() {
self.selected = (self.selected + 1).min(self.offers.len() - 1);
}
LocalServerAction::None
}
_ => LocalServerAction::None,
}
}
pub fn min_size(loc: &'static Locale) -> MinSize {
MinSize::new(legend_width(loc.t("ui.local_servers.footer")), 5)
}
fn row_text(offer: &LocalOffer, loc: &'static Locale) -> (String, String) {
let server = offer.server.product().unwrap_or(&offer.url);
let head = format!("{server} · {}", offer.model);
let mut tail = String::new();
if offer.loaded {
tail.push_str(&format!(" {}", loc.t("ui.local_servers.loaded")));
}
if let Some(embedder) = &offer.embedder {
tail.push_str(&format!(
" {}",
loc.tf("ui.local_servers.embeddings", &[("model", embedder)])
));
}
(head, tail)
}
pub fn render(
&mut self,
frame: &mut Frame,
area: Rect,
palette: &Palette,
loc: &'static Locale,
) {
let rows = (self.offers.len() as u16 + 2).max(5).min(area.height);
let popup = centered_rect(70, 40.max(Self::min_size(loc).width), rows, area);
frame.render_widget(Clear, popup);
let block = palette
.panel(
format!(
"{}{}",
palette.glyphs().settings_icon,
loc.t("ui.local_servers.title")
),
true,
)
.title_bottom(Line::from(Span::styled(
loc.t("ui.local_servers.footer"),
palette.muted_style(),
)));
let mark = palette.selected_mark().map_or(0, wrap::str_width);
let width = (popup.width as usize).saturating_sub(2 + mark);
let items: Vec<ListItem> = self
.offers
.iter()
.map(|offer| {
let (head, tail) = Self::row_text(offer, loc);
let (head, _) = wrap::truncate_to_width(&head, width);
let (tail, _) =
wrap::truncate_to_width(&tail, width.saturating_sub(wrap::str_width(&head)));
ListItem::new(Line::from(vec![
Span::styled(head, Style::new().fg(palette.text)),
Span::styled(tail, palette.muted_style()),
]))
})
.collect();
let list = mark_selected(
List::new(items)
.block(block)
.highlight_style(Style::new().reversed()),
palette,
);
self.scroll.render(
frame,
list,
popup,
self.offers.len(),
popup.height.saturating_sub(2) as usize, (!self.offers.is_empty()).then_some(self.selected),
);
}
}
fn centered_rect(pct_x: u16, min_w: u16, height: u16, area: Rect) -> Rect {
let w = area.width.saturating_mul(pct_x) / 100;
let [h_area] = Layout::horizontal([Constraint::Length(w.max(min_w).min(area.width))])
.flex(Flex::Center)
.areas(area);
let [v_area] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(h_area);
v_area
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::api::ServerKind;
use ratatui::crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
pub(crate) fn offers() -> Vec<LocalOffer> {
vec![
LocalOffer {
server: ServerKind::LmStudio,
url: "http://127.0.0.1:1234/v1".into(),
model: "google_gemma-4-e4b-it".into(),
loaded: true,
embedder: Some("text-embedding-nomic-embed-text-v1.5".into()),
},
LocalOffer {
server: ServerKind::Ollama,
url: "http://127.0.0.1:11434/v1".into(),
model: "gemma4:e4b".into(),
loaded: false,
embedder: None,
},
]
}
#[test]
fn enter_picks_the_selected_row_and_esc_changes_nothing() {
let mut s = LocalServerPickerState::new(offers());
assert_eq!(s.on_key(key(KeyCode::Down)), LocalServerAction::None);
assert_eq!(
s.on_key(key(KeyCode::Enter)),
LocalServerAction::Pick(offers()[1].clone())
);
assert_eq!(s.on_key(key(KeyCode::Esc)), LocalServerAction::Cancel);
s.on_key(key(KeyCode::Down));
assert_eq!(
s.on_key(key(KeyCode::Enter)),
LocalServerAction::Pick(offers()[1].clone())
);
for _ in 0..3 {
s.on_key(key(KeyCode::Up));
}
assert_eq!(
s.on_key(key(KeyCode::Enter)),
LocalServerAction::Pick(offers()[0].clone())
);
}
fn drawn(s: &mut LocalServerPickerState, width: u16, loc: &'static Locale) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(width, 12)).unwrap();
term.draw(|f| s.render(f, f.area(), &Palette::default(), loc))
.unwrap();
crate::shared::ui::tests::buffer_rows(term.backend().buffer())
}
#[test]
fn a_row_names_the_server_the_model_and_the_embedder() {
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let rows = drawn(&mut LocalServerPickerState::new(offers()), 160, loc);
let lm = rows
.iter()
.find(|r| r.contains("LM Studio · google_gemma-4-e4b-it"))
.unwrap_or_else(|| panic!("{rows:#?}"));
assert!(lm.contains("nomic-embed-text-v1.5"), "{lm}");
let ollama = rows
.iter()
.find(|r| r.contains("Ollama · gemma4:e4b"))
.unwrap_or_else(|| panic!("{rows:#?}"));
assert!(!ollama.contains("embed"), "{ollama}");
}
#[test]
fn the_list_is_never_narrower_than_its_legend() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let legend = loc.t("ui.local_servers.footer").trim();
let need = LocalServerPickerState::min_size(loc);
for width in need.width..=120 {
let rows = drawn(&mut LocalServerPickerState::new(offers()), width, loc);
assert!(
rows.iter().any(|r| r.contains(legend)),
"{lang:?} {width}: {rows:#?}"
);
}
}
}
#[test]
fn render_does_not_panic() {
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
for (w, h) in [(80u16, 24u16), (20, 6), (1, 1)] {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut s = LocalServerPickerState::new(offers());
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| s.render(f, f.area(), &Palette::default(), loc))
.unwrap();
}
}
}