use std::collections::BTreeMap;
use bcode_markdown_render::MarkdownContributionKind;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisibleMarkdownContribution {
pub id: String,
pub kind: MarkdownContributionKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MarkdownInteractionState {
focused: Option<String>,
visible_order: Vec<String>,
visible: BTreeMap<String, MarkdownContributionKind>,
}
impl MarkdownInteractionState {
pub fn reconcile(&mut self, contributions: Vec<VisibleMarkdownContribution>) {
let previous_index = self
.focused
.as_ref()
.and_then(|focused| self.visible_order.iter().position(|id| id == focused));
let mut visible_order = Vec::new();
let mut visible = BTreeMap::new();
for contribution in contributions {
if visible
.insert(contribution.id.clone(), contribution.kind)
.is_none()
{
visible_order.push(contribution.id);
}
}
if self
.focused
.as_ref()
.is_some_and(|focused| visible.contains_key(focused))
{
self.visible_order = visible_order;
self.visible = visible;
return;
}
self.visible_order = visible_order;
self.visible = visible;
self.focused = previous_index
.and_then(|index| {
self.visible_order
.get(index)
.or_else(|| self.visible_order.last())
})
.cloned();
}
pub fn focus_next(&mut self) -> bool {
self.move_focus(false)
}
pub fn focus_previous(&mut self) -> bool {
self.move_focus(true)
}
pub fn focus(&mut self, contribution_id: &str) -> bool {
if !self.visible.contains_key(contribution_id) {
return false;
}
let changed = self.focused.as_deref() != Some(contribution_id);
self.focused = Some(contribution_id.to_owned());
changed
}
pub fn focus_target(&mut self, contribution_id: &str) -> bool {
self.focus(contribution_id)
}
#[must_use]
pub fn focused(&self) -> Option<&str> {
self.focused.as_deref()
}
#[must_use]
pub fn contribution(&self, contribution_id: &str) -> Option<&MarkdownContributionKind> {
self.visible.get(contribution_id)
}
fn move_focus(&mut self, reverse: bool) -> bool {
if self.visible_order.is_empty() {
return self.focused.take().is_some();
}
let next = self.focused.as_ref().and_then(|focused| {
self.visible_order
.iter()
.position(|id| id == focused)
.map(|index| {
if reverse {
index.checked_sub(1).unwrap_or(self.visible_order.len() - 1)
} else {
(index + 1) % self.visible_order.len()
}
})
});
self.focused = Some(
self.visible_order[next.unwrap_or_else(|| {
if reverse {
self.visible_order.len() - 1
} else {
0
}
})]
.clone(),
);
true
}
}
#[must_use]
pub fn contribution_id_from_hit(hit_id: &str) -> Option<&str> {
hit_id
.strip_prefix("markdown:")?
.rsplit_once(':')
.map(|(contribution_id, _)| contribution_id)
.filter(|contribution_id| !contribution_id.is_empty())
}
#[cfg(test)]
mod tests {
use bcode_markdown_render::{
MarkdownContributionKind, MarkdownDestination, MarkdownLink, MarkdownLinkKind,
};
use super::{MarkdownInteractionState, VisibleMarkdownContribution, contribution_id_from_hit};
fn contribution(id: &str) -> VisibleMarkdownContribution {
VisibleMarkdownContribution {
id: id.to_owned(),
kind: MarkdownContributionKind::Link {
link: MarkdownLink {
destination: "https://example.com".to_owned(),
title: None,
reference_id: None,
kind: MarkdownLinkKind::Inline,
},
label: id.to_owned(),
destination: MarkdownDestination::Web(
"https://example.com".parse().expect("valid URL"),
),
},
}
}
#[test]
fn traversal_wraps_in_both_directions() {
let mut state = MarkdownInteractionState::default();
state.reconcile(vec![contribution("a"), contribution("b")]);
assert!(state.focus_next());
assert_eq!(state.focused(), Some("a"));
assert!(state.focus_next());
assert_eq!(state.focused(), Some("b"));
assert!(state.focus_next());
assert_eq!(state.focused(), Some("a"));
assert!(state.focus_previous());
assert_eq!(state.focused(), Some("b"));
}
#[test]
fn reconciliation_preserves_identity_payload_and_disappeared_position() {
let mut state = MarkdownInteractionState::default();
state.reconcile(vec![
contribution("a"),
contribution("b"),
contribution("c"),
]);
state.focus_next();
state.focus_next();
state.reconcile(vec![
contribution("x"),
contribution("b"),
contribution("c"),
]);
assert_eq!(state.focused(), Some("b"));
assert!(state.contribution("b").is_some());
state.reconcile(vec![contribution("b"), contribution("c")]);
assert_eq!(state.focused(), Some("b"));
state.reconcile(vec![contribution("x"), contribution("c")]);
assert_eq!(state.focused(), Some("x"));
assert!(state.contribution("b").is_none());
state.reconcile(Vec::new());
assert_eq!(state.focused(), None);
assert!(state.contribution("c").is_none());
}
#[test]
fn resize_rect_duplicates_do_not_duplicate_focus_order() {
let mut state = MarkdownInteractionState::default();
state.reconcile(vec![
contribution("wrapped"),
contribution("wrapped"),
contribution("next"),
]);
state.focus_next();
assert_eq!(state.focused(), Some("wrapped"));
state.focus_next();
assert_eq!(state.focused(), Some("next"));
}
#[test]
fn mouse_hit_identity_preserves_colons_in_contribution_id() {
assert_eq!(
contribution_id_from_hit("markdown:transcript:7:link:12:3"),
Some("transcript:7:link:12")
);
assert_eq!(contribution_id_from_hit("transcript"), None);
}
}