use super::{PICKER_MODAL, draw_modal_panel_with_hotkeys, modal_text_area};
use crate::tui::{
input::THEME_VARIANT_HELP,
modal_container::modal_tab,
state::{MissionControlState, ThemeSaveState},
};
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::{List, ListItem, Paragraph},
};
use unicode_width::UnicodeWidthStr;
use unicode_segmentation::UnicodeSegmentation;
const THEME_PICKER_FOOTER_ROWS: u16 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ThemePickerGeometry {
tabs: Rect,
query: Rect,
list: Rect,
footer: Rect,
}
fn theme_picker_geometry(area: Rect) -> Option<ThemePickerGeometry> {
let inner = modal_text_area(area, "Themes", PICKER_MODAL)?;
let tabs = Rect::new(inner.x, inner.y, inner.width, inner.height.min(1));
let query = Rect::new(
inner.x,
tabs.bottom(),
inner.width,
inner.bottom().saturating_sub(tabs.bottom()).min(1),
);
let list_y = query.bottom();
let footer_height = inner
.bottom()
.saturating_sub(list_y)
.min(THEME_PICKER_FOOTER_ROWS);
let footer_y = inner.bottom().saturating_sub(footer_height);
let list = Rect::new(
inner.x,
list_y,
inner.width,
footer_y.saturating_sub(list_y),
);
let footer = Rect::new(inner.x, footer_y, inner.width, footer_height);
Some(ThemePickerGeometry {
tabs,
query,
list,
footer,
})
}
pub(crate) fn theme_picker_query_visible(area: Rect) -> bool {
theme_picker_geometry(area)
.is_some_and(|geometry| geometry.query.width > 0 && geometry.query.height > 0)
}
pub(crate) fn theme_picker_list_area(area: Rect) -> Option<Rect> {
theme_picker_geometry(area)
.and_then(|geometry| (geometry.list.height > 0).then_some(geometry.list))
}
fn query_window(query: &str, cursor: usize, width: usize) -> (&str, usize) {
let cursor = cursor.min(query.len());
let mut start = cursor;
let mut used = 0;
for (index, grapheme) in query[..cursor].grapheme_indices(true).rev() {
let next = UnicodeWidthStr::width(grapheme);
if used + next > width.saturating_sub(1) {
break;
}
used += next;
start = index;
}
(&query[start..], used)
}
pub(in crate::tui::render) fn draw_theme_picker(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
) {
let Some(picker) = &state.modals.theme_picker else {
return;
};
let Some(geometry) = theme_picker_geometry(area) else {
return;
};
let pending = matches!(picker.save_state, ThemeSaveState::Pending);
let mut hotkeys = format!("{THEME_VARIANT_HELP} • [↑/↓] preview • [Enter] save");
if hotkeys.width() > usize::from(geometry.footer.width) {
hotkeys = format!("{THEME_VARIANT_HELP} • [↑/↓] • [Enter] save");
}
let Some(inner) = draw_modal_panel_with_hotkeys(
frame,
area,
"Themes",
PICKER_MODAL,
state.theme,
if pending { "Saving theme…" } else { &hotkeys },
if pending { None } else { Some("[Esc] close") },
) else {
return;
};
if inner.width == 0 || inner.height == 0 {
return;
}
frame.render_widget(
Paragraph::new(Line::from(vec![
modal_tab(
"Dark",
picker.active_variant == opaline::ThemeVariant::Dark,
state.theme,
),
modal_tab(
"Light",
picker.active_variant == opaline::ThemeVariant::Light,
state.theme,
),
])),
geometry.tabs,
);
let query = &picker.query;
let query_width = geometry.query.width.saturating_sub(8) as usize;
let (visible_query, cursor) = query_window(query, picker.query_cursor, query_width);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("Search: "),
Span::raw(visible_query),
])),
geometry.query,
);
let content = geometry.list;
if (picker.loading || picker.error.is_some()) && picker.entries.is_empty() {
let message = if picker.loading {
"Loading themes…".to_string()
} else {
format!(
"Theme catalog error: {}",
picker.error.as_deref().unwrap_or("unknown error")
)
};
frame.render_widget(Paragraph::new(message), content);
} else if picker.filtered_indices.is_empty() {
let message = match picker.active_variant {
opaline::ThemeVariant::Dark => "No matching dark themes",
opaline::ThemeVariant::Light => "No matching light themes",
};
frame.render_widget(Paragraph::new(message), content);
} else {
let items = picker
.filtered_indices
.iter()
.skip(state.scroll_offset(&state.scroll_views.theme_modal))
.take(content.height as usize)
.filter_map(|index| picker.entries.get(*index))
.map(|entry| {
let marker = if entry.id == picker.preview_theme_id {
"› "
} else {
" "
};
ListItem::new(format!("{marker}{} — {}", entry.id, entry.display_name))
})
.collect::<Vec<_>>();
debug_assert_eq!(
items.len(),
picker
.filtered_indices
.iter()
.skip(state.scroll_offset(&state.scroll_views.theme_modal))
.take(content.height as usize)
.count()
);
frame.render_widget(List::new(items), content);
}
let mut footer_y = geometry.footer.y;
if let Some(notice) = &picker.notice
&& footer_y < geometry.footer.bottom()
{
frame.render_widget(
Paragraph::new(notice.clone()),
Rect::new(geometry.footer.x, footer_y, geometry.footer.width, 1),
);
footer_y = footer_y.saturating_add(1);
}
if matches!(picker.save_state, ThemeSaveState::Pending) && footer_y < geometry.footer.bottom() {
frame.render_widget(
Paragraph::new("Saving theme…"),
Rect::new(geometry.footer.x, footer_y, geometry.footer.width, 1),
);
} else if let ThemeSaveState::Failed(error) = &picker.save_state
&& footer_y < geometry.footer.bottom()
{
frame.render_widget(
Paragraph::new(format!("Save failed: {error}")),
Rect::new(geometry.footer.x, footer_y, geometry.footer.width, 1),
);
}
if geometry.query.width > 8 && geometry.query.height > 0 {
let cursor_x = geometry
.query
.x
.saturating_add(8)
.saturating_add(cursor.min(query_width) as u16);
if cursor_x < geometry.query.right() {
frame.set_cursor_position((cursor_x, geometry.query.y));
}
}
}