#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::sanitize::sanitize_str;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PaneId {
#[default]
Main,
Sidebar,
Prompt,
}
impl PaneId {
pub fn next(self) -> Self {
match self {
Self::Main => Self::Sidebar,
Self::Sidebar => Self::Prompt,
Self::Prompt => Self::Main,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum OverlayKind {
Help,
CommandPalette,
Search,
Inspector,
Settings,
ThemeEditor,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum FocusDirection {
Forward,
Backward,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FocusTarget {
pub id: String,
pub disabled: bool,
}
impl FocusTarget {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: sanitize_str(&id.into(), 120),
disabled: false,
}
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FocusChain {
pub targets: Vec<FocusTarget>,
pub active_index: Option<usize>,
}
impl FocusChain {
pub fn new(targets: impl IntoIterator<Item = FocusTarget>) -> Self {
let targets = targets.into_iter().collect::<Vec<_>>();
let active_index = targets.iter().position(|target| !target.disabled);
Self {
targets,
active_index,
}
}
pub fn active(&self) -> Option<&FocusTarget> {
self.active_index
.and_then(|index| self.targets.get(index))
.filter(|target| !target.disabled)
}
pub fn active_id(&self) -> Option<&str> {
self.active().map(|target| target.id.as_str())
}
pub fn set_active(&mut self, id: &str) -> bool {
let id = sanitize_str(id, 120);
if let Some(index) = self
.targets
.iter()
.position(|target| target.id == id && !target.disabled)
{
self.active_index = Some(index);
true
} else {
false
}
}
pub fn move_focus(&mut self, direction: FocusDirection) -> Option<&FocusTarget> {
let len = self.targets.len();
if len == 0 || self.targets.iter().all(|target| target.disabled) {
self.active_index = None;
return None;
}
let base = self
.active_index
.filter(|index| *index < len)
.unwrap_or(match direction {
FocusDirection::Forward => len - 1,
FocusDirection::Backward => 0,
});
for step in 1..=len {
let index = match direction {
FocusDirection::Forward => (base + step) % len,
FocusDirection::Backward => (base + len - step % len) % len,
};
if !self.targets[index].disabled {
self.active_index = Some(index);
return self.active();
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn focus_chain_skips_disabled_targets_and_wraps() {
let mut chain = FocusChain::new([
FocusTarget::new("editor"),
FocusTarget::new("disabled").disabled(true),
FocusTarget::new("prompt"),
]);
assert_eq!(chain.active_id(), Some("editor"));
assert_eq!(
chain
.move_focus(FocusDirection::Forward)
.map(|target| target.id.as_str()),
Some("prompt")
);
assert_eq!(
chain
.move_focus(FocusDirection::Forward)
.map(|target| target.id.as_str()),
Some("editor")
);
assert_eq!(
chain
.move_focus(FocusDirection::Backward)
.map(|target| target.id.as_str()),
Some("prompt")
);
}
#[test]
fn focus_chain_refuses_disabled_or_missing_active_targets() {
let mut chain = FocusChain::new([
FocusTarget::new("one").disabled(true),
FocusTarget::new("two"),
]);
assert_eq!(chain.active_id(), Some("two"));
assert!(!chain.set_active("one"));
assert!(!chain.set_active("missing"));
assert!(chain.set_active("two"));
assert_eq!(chain.active_id(), Some("two"));
}
#[test]
fn focus_chain_reports_none_when_all_targets_are_disabled() {
let mut chain = FocusChain::new([
FocusTarget::new("one").disabled(true),
FocusTarget::new("two").disabled(true),
]);
assert_eq!(chain.active_id(), None);
assert_eq!(chain.move_focus(FocusDirection::Forward), None);
assert_eq!(chain.active_index, None);
}
#[test]
fn focus_chain_recovers_from_out_of_range_public_active_index() {
let mut chain = FocusChain {
targets: vec![FocusTarget::new("first"), FocusTarget::new("second")],
active_index: Some(usize::MAX),
};
assert_eq!(chain.active_id(), None);
assert_eq!(
chain
.move_focus(FocusDirection::Forward)
.map(|target| target.id.as_str()),
Some("first")
);
}
#[test]
fn focus_chain_set_active_sanitizes_lookup_id() {
let mut chain = FocusChain::new([FocusTarget::new("pane\u{200f}\x1b[31m")]);
assert_eq!(chain.active_id(), Some("pane"));
chain.active_index = None;
assert!(chain.set_active("pane\u{200f}\x1b[31m"));
assert_eq!(chain.active_id(), Some("pane"));
}
}