pub(crate) const MODEL_PAGE_SIZE: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelPage {
pub(crate) models: Vec<String>,
pub(crate) page: usize,
pub(crate) total_pages: usize,
pub(crate) matched: usize,
pub(crate) filter: Option<String>,
}
impl ModelPage {
pub(crate) fn has_prev(&self) -> bool {
self.page > 0
}
pub(crate) fn has_next(&self) -> bool {
self.page + 1 < self.total_pages
}
}
pub(crate) fn page_of(models: &[String], page: usize, filter: Option<&str>) -> ModelPage {
let needle = filter
.map(|f| f.trim().to_lowercase())
.filter(|f| !f.is_empty());
let matched: Vec<String> = match &needle {
Some(f) => models
.iter()
.filter(|m| m.to_lowercase().contains(f.as_str()))
.cloned()
.collect(),
None => models.to_vec(),
};
let total_pages = matched.len().div_ceil(MODEL_PAGE_SIZE).max(1);
let page = page.min(total_pages - 1);
let start = page * MODEL_PAGE_SIZE;
ModelPage {
models: matched
.iter()
.skip(start)
.take(MODEL_PAGE_SIZE)
.cloned()
.collect(),
page,
total_pages,
matched: matched.len(),
filter: needle,
}
}
pub(crate) fn page_text(
display_name: &str,
current_model: &str,
total_models: usize,
page: &ModelPage,
) -> String {
let mut lines = vec![format!("🤖 *{display_name} Models*")];
lines.push(format!("Current: `{current_model}`"));
match (&page.filter, page.matched) {
(Some(f), 0) => {
lines.push(String::new());
lines.push(format!("No model matches `{f}` (of {total_models})."));
lines.push("Send /models with a different filter to search again.".into());
return lines.join("\n");
}
(Some(f), n) => lines.push(format!("Filter: `{f}` — {n} of {total_models} match")),
(None, n) => lines.push(format!("{n} available")),
}
if page.total_pages > 1 {
lines.push(format!(
"Page {}/{} — use ◀ ▶ to page",
page.page + 1,
page.total_pages
));
}
lines.push(String::new());
lines.push("Tap a model below (✓ = current).".into());
if page.total_pages > 1 {
lines.push("/models <text> filters by name.".into());
}
lines.join("\n")
}
pub(crate) fn page_keyboard(
provider_name: &str,
current_model: &str,
page: &ModelPage,
) -> Vec<Vec<teloxide::types::InlineKeyboardButton>> {
use teloxide::types::InlineKeyboardButton;
crate::channels::model_menu::remember(provider_name, &page.models);
let mut rows: Vec<Vec<InlineKeyboardButton>> = page
.models
.iter()
.enumerate()
.map(|(i, m)| {
let display = if m == current_model {
format!("✓ {m}")
} else {
m.clone()
};
let data = crate::channels::commands::model_button_callback_data(provider_name, m, i);
vec![InlineKeyboardButton::callback(display, data)]
})
.collect();
if page.total_pages > 1 {
let mut nav = Vec::new();
if page.has_prev() {
nav.push(InlineKeyboardButton::callback(
"◀ Prev".to_string(),
nav_callback_data(provider_name, page.page - 1, page.filter.as_deref()),
));
}
nav.push(InlineKeyboardButton::callback(
format!("{}/{}", page.page + 1, page.total_pages),
"noop".to_string(),
));
if page.has_next() {
nav.push(InlineKeyboardButton::callback(
"Next ▶".to_string(),
nav_callback_data(provider_name, page.page + 1, page.filter.as_deref()),
));
}
rows.push(nav);
}
rows
}
pub(crate) fn nav_callback_data(provider_name: &str, page: usize, filter: Option<&str>) -> String {
let base = format!("mp:{page}|{provider_name}");
match filter {
Some(f) if base.len() + f.len() < 64 => format!("{base}|{f}"),
_ => base,
}
}
pub(crate) fn parse_nav_callback(data: &str) -> Option<(usize, String, Option<String>)> {
let rest = data.strip_prefix("mp:")?;
let (page_str, tail) = rest.split_once('|')?;
let page = page_str.parse().ok()?;
match tail.split_once('|') {
Some((provider, filter)) => Some((page, provider.to_string(), Some(filter.to_string()))),
None => Some((page, tail.to_string(), None)),
}
}