use bmux_keyboard::KeyStroke;
use bmux_tui_components::selectable_list::SelectableListState as ListState;
use std::collections::BTreeMap;
use super::filtered_list::FilteredListState;
use super::theme::{ResolvedTheme, ThemeCatalogEntry};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ThemePickerOutcome {
Preview(String),
Apply(String),
Cancel,
Ignored,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemePickerState {
entries: Vec<ThemeCatalogEntry>,
resolved_previews: BTreeMap<String, ResolvedTheme>,
diagnostics: Vec<String>,
list: FilteredListState,
}
impl ThemePickerState {
#[must_use]
pub fn new(entries: Vec<ThemeCatalogEntry>, diagnostics: Vec<String>) -> Self {
let selected = entries.iter().position(|entry| entry.selected).unwrap_or(0);
let mut list = FilteredListState::new(entries.len());
let _selected = list.select_visible(selected);
Self {
entries,
resolved_previews: BTreeMap::new(),
diagnostics,
list,
}
}
#[must_use]
pub fn entries(&self) -> &[ThemeCatalogEntry] {
&self.entries
}
pub fn set_resolved_previews(&mut self, resolved: BTreeMap<String, ResolvedTheme>) {
self.resolved_previews = resolved;
}
#[must_use]
pub fn resolved_preview(&self, id: &str) -> Option<ResolvedTheme> {
self.resolved_previews.get(id).copied()
}
#[must_use]
pub fn diagnostics(&self) -> &[String] {
&self.diagnostics
}
pub fn list_render_state(&mut self, viewport_height: u16) -> &mut ListState {
self.list.render_state(viewport_height)
}
#[must_use]
pub fn list_offset(&self) -> usize {
self.list.offset()
}
#[must_use]
pub fn selected_entry(&self) -> Option<&ThemeCatalogEntry> {
self.list
.selected_source_index()
.and_then(|selected| self.entries.get(selected))
}
#[must_use]
pub fn selected_id(&self) -> Option<&str> {
self.selected_entry().map(|entry| entry.id.as_str())
}
pub fn select_row(&mut self, row: usize) -> ThemePickerOutcome {
let Some(entry) = self.entries.get(row) else {
return ThemePickerOutcome::Ignored;
};
let _selected = self.list.select_visible(row);
ThemePickerOutcome::Preview(entry.id.clone())
}
pub fn activate_row(&mut self, row: usize) -> ThemePickerOutcome {
let Some(entry) = self.entries.get(row) else {
return ThemePickerOutcome::Ignored;
};
let _selected = self.list.select_visible(row);
ThemePickerOutcome::Apply(entry.id.clone())
}
#[cfg(test)]
pub fn handle_key(&mut self, stroke: KeyStroke) -> ThemePickerOutcome {
self.handle_key_with_keymap(
stroke,
&super::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default()),
)
}
pub fn handle_key_with_keymap(
&mut self,
stroke: KeyStroke,
keymap: &super::keymap::BmuxKeyMap,
) -> ThemePickerOutcome {
match keymap.action("theme_picker", stroke) {
Some("tui.control.cancel") => ThemePickerOutcome::Cancel,
Some("tui.control.submit") => self
.selected_id()
.map_or(ThemePickerOutcome::Ignored, |id| {
ThemePickerOutcome::Apply(id.to_owned())
}),
Some("tui.control.up") => self.move_selection(-1),
Some("tui.control.down") => self.move_selection(1),
_ => ThemePickerOutcome::Ignored,
}
}
fn move_selection(&mut self, delta: isize) -> ThemePickerOutcome {
if self.entries.is_empty() {
return ThemePickerOutcome::Ignored;
}
if delta < 0 {
self.list.select_previous();
} else {
self.list.select_next();
}
self.selected_id()
.map_or(ThemePickerOutcome::Ignored, |id| {
ThemePickerOutcome::Preview(id.to_owned())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use bmux_keyboard::KeyCode;
fn entry(id: &str, selected: bool) -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: id.to_owned(),
display_name: id.to_owned(),
source: "bundled".to_owned(),
has_dark_variant: false,
has_light_variant: false,
validation: "valid".to_owned(),
selected,
}
}
#[test]
fn remapped_preview_and_unbound_cancel_have_no_default_fallback() {
let mut config = bcode_config::TuiConfig::default();
config.keybindings.scopes.insert(
"theme_picker".into(),
BTreeMap::from([
("ctrl+n".into(), "tui.control.down".into()),
("escape".into(), String::new()),
]),
);
let keymap = super::super::keymap::BmuxKeyMap::from_config(&config);
let mut picker =
ThemePickerState::new(vec![entry("one", true), entry("two", false)], Vec::new());
assert_eq!(
picker.handle_key_with_keymap(KeyStroke::simple(KeyCode::Down), &keymap),
ThemePickerOutcome::Ignored
);
assert_eq!(picker.selected_id(), Some("one"));
assert_eq!(
picker.handle_key_with_keymap(
bmux_keyboard::parse_key_stroke("ctrl+n").unwrap(),
&keymap
),
ThemePickerOutcome::Preview("two".into())
);
assert_eq!(
picker.handle_key_with_keymap(KeyStroke::simple(KeyCode::Escape), &keymap),
ThemePickerOutcome::Ignored
);
}
#[test]
fn mouse_row_selection_previews_then_activates() {
let mut picker =
ThemePickerState::new(vec![entry("one", true), entry("two", false)], Vec::new());
assert_eq!(
picker.select_row(1),
ThemePickerOutcome::Preview("two".to_owned())
);
assert_eq!(
picker.activate_row(1),
ThemePickerOutcome::Apply("two".to_owned())
);
assert_eq!(picker.selected_id(), Some("two"));
}
#[test]
fn resolved_previews_are_retrieved_without_re_resolving() {
let mut picker = ThemePickerState::new(vec![entry("bcode-dark", true)], Vec::new());
let catalog =
super::super::theme::definition::ThemeCatalog::bundled().expect("bundled themes parse");
let definition = catalog
.resolve(&super::super::theme::definition::ThemeSelection::new(
"bcode-dark",
))
.expect("dark resolves");
let target = super::super::theme::resolved_definition_theme(
Some(&definition),
super::super::theme::PENDING_AGENT_METADATA_ACCENT,
);
picker.set_resolved_previews(BTreeMap::from([("bcode-dark".to_owned(), target)]));
assert_eq!(picker.resolved_preview("bcode-dark"), Some(target));
assert_eq!(picker.resolved_preview("missing"), None);
}
#[test]
fn movement_previews_and_escape_cancels() {
let mut picker =
ThemePickerState::new(vec![entry("one", true), entry("two", false)], Vec::new());
assert_eq!(
picker.handle_key(KeyStroke::simple(KeyCode::Down)),
ThemePickerOutcome::Preview("two".to_owned())
);
assert_eq!(
picker.handle_key(KeyStroke::simple(KeyCode::Enter)),
ThemePickerOutcome::Apply("two".to_owned())
);
assert_eq!(
picker.handle_key(KeyStroke::simple(KeyCode::Escape)),
ThemePickerOutcome::Cancel
);
}
}