feat(tree): close rust family shell cutover
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
use super::focus_state::TreeShellFocusState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageTreeRuntimeEnvironment {
|
||||
pub visible_node_ids: Vec<String>,
|
||||
pub expandable_node_ids: BTreeSet<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rows: Vec<PageTreeRuntimeRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageTreeRuntimeRow {
|
||||
pub node_id: String,
|
||||
pub parent_node_id: Option<String>,
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageTreeRuntimeState {
|
||||
pub focused_id: Option<String>,
|
||||
pub expanded_ids: BTreeSet<String>,
|
||||
pub drop_feedback: Option<PageTreeDropFeedback>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageTreeRuntimeTransition {
|
||||
pub state: PageTreeRuntimeState,
|
||||
pub outputs: BTreeSet<PageTreeRuntimeOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum PageTreeRuntimeAction {
|
||||
Normalize,
|
||||
Focus {
|
||||
node_id: String,
|
||||
},
|
||||
MoveNext,
|
||||
MovePrevious,
|
||||
MoveHome,
|
||||
MoveEnd,
|
||||
Expand {
|
||||
node_id: String,
|
||||
},
|
||||
Collapse {
|
||||
node_id: String,
|
||||
},
|
||||
Toggle {
|
||||
node_id: String,
|
||||
},
|
||||
OpenFocused,
|
||||
ContextMenuFocused,
|
||||
DispatchCreate {
|
||||
parent_node_id: Option<String>,
|
||||
},
|
||||
DispatchRename {
|
||||
node_id: String,
|
||||
title: String,
|
||||
},
|
||||
UpdateDropFeedback {
|
||||
feedback: Option<PageTreeDropFeedback>,
|
||||
},
|
||||
UpdateDropFeedbackForTarget {
|
||||
source_node_id: String,
|
||||
target_node_id: String,
|
||||
position: PageTreeDropPosition,
|
||||
},
|
||||
DispatchMove {
|
||||
source_node_id: String,
|
||||
target_parent_id: Option<String>,
|
||||
position: PageTreeDropPosition,
|
||||
},
|
||||
DispatchMoveToTarget {
|
||||
source_node_id: String,
|
||||
target_node_id: String,
|
||||
position: PageTreeDropPosition,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum PageTreeRuntimeOutput {
|
||||
DomPatch,
|
||||
Intent(PageTreeIntentEvent),
|
||||
CommandDispatch(PageTreeCommandDispatchEvent),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum PageTreeIntentEvent {
|
||||
Open { node_id: String },
|
||||
ContextMenu { node_id: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum PageTreeCommandDispatchEvent {
|
||||
CreateNode {
|
||||
command_name: &'static str,
|
||||
parent_node_id: Option<String>,
|
||||
},
|
||||
RenameNode {
|
||||
command_name: &'static str,
|
||||
node_id: String,
|
||||
title: String,
|
||||
},
|
||||
MoveSubtree {
|
||||
command_name: &'static str,
|
||||
source_node_id: String,
|
||||
target_node_id: Option<String>,
|
||||
target_parent_id: Option<String>,
|
||||
position: PageTreeDropPosition,
|
||||
sort_order: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageTreeDropFeedback {
|
||||
pub source_node_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_node_id: Option<String>,
|
||||
pub target_parent_id: Option<String>,
|
||||
pub position: PageTreeDropPosition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PageTreeDropPosition {
|
||||
Before,
|
||||
Inside,
|
||||
After,
|
||||
}
|
||||
|
||||
impl PageTreeRuntimeState {
|
||||
pub fn reduce(
|
||||
&self,
|
||||
env: &PageTreeRuntimeEnvironment,
|
||||
action: PageTreeRuntimeAction,
|
||||
) -> PageTreeRuntimeTransition {
|
||||
match action {
|
||||
PageTreeRuntimeAction::Normalize => self.with_focus(
|
||||
TreeShellFocusState {
|
||||
focused_id: self.focused_id.clone(),
|
||||
}
|
||||
.normalize(&env.visible_node_ids)
|
||||
.focused_id,
|
||||
),
|
||||
PageTreeRuntimeAction::Focus { node_id } => {
|
||||
let focused_id = env
|
||||
.visible_node_ids
|
||||
.iter()
|
||||
.any(|visible_id| visible_id == &node_id)
|
||||
.then_some(node_id);
|
||||
self.with_focus(focused_id)
|
||||
}
|
||||
PageTreeRuntimeAction::MoveNext => self.with_focus(
|
||||
TreeShellFocusState {
|
||||
focused_id: self.focused_id.clone(),
|
||||
}
|
||||
.move_next(&env.visible_node_ids)
|
||||
.focused_id,
|
||||
),
|
||||
PageTreeRuntimeAction::MovePrevious => self.with_focus(
|
||||
TreeShellFocusState {
|
||||
focused_id: self.focused_id.clone(),
|
||||
}
|
||||
.move_previous(&env.visible_node_ids)
|
||||
.focused_id,
|
||||
),
|
||||
PageTreeRuntimeAction::MoveHome => self.with_focus(
|
||||
TreeShellFocusState {
|
||||
focused_id: self.focused_id.clone(),
|
||||
}
|
||||
.move_home(&env.visible_node_ids)
|
||||
.focused_id,
|
||||
),
|
||||
PageTreeRuntimeAction::MoveEnd => self.with_focus(
|
||||
TreeShellFocusState {
|
||||
focused_id: self.focused_id.clone(),
|
||||
}
|
||||
.move_end(&env.visible_node_ids)
|
||||
.focused_id,
|
||||
),
|
||||
PageTreeRuntimeAction::Expand { node_id } => self.with_expansion(env, node_id, true),
|
||||
PageTreeRuntimeAction::Collapse { node_id } => self.with_expansion(env, node_id, false),
|
||||
PageTreeRuntimeAction::Toggle { node_id } => {
|
||||
let expanded = !self.expanded_ids.contains(&node_id);
|
||||
self.with_expansion(env, node_id, expanded)
|
||||
}
|
||||
PageTreeRuntimeAction::OpenFocused => {
|
||||
self.with_focused_intent(PageTreeIntentEvent::Open {
|
||||
node_id: self.focused_id.clone().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
PageTreeRuntimeAction::ContextMenuFocused => {
|
||||
self.with_focused_intent(PageTreeIntentEvent::ContextMenu {
|
||||
node_id: self.focused_id.clone().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
PageTreeRuntimeAction::DispatchCreate { parent_node_id } => transition(
|
||||
self.clone(),
|
||||
[PageTreeRuntimeOutput::CommandDispatch(
|
||||
PageTreeCommandDispatchEvent::CreateNode {
|
||||
command_name: "tree.node.create",
|
||||
parent_node_id,
|
||||
},
|
||||
)],
|
||||
),
|
||||
PageTreeRuntimeAction::DispatchRename { node_id, title } => {
|
||||
let title = title.trim().to_string();
|
||||
if node_id.trim().is_empty() || title.is_empty() {
|
||||
return transition(self.clone(), []);
|
||||
}
|
||||
transition(
|
||||
self.clone(),
|
||||
[PageTreeRuntimeOutput::CommandDispatch(
|
||||
PageTreeCommandDispatchEvent::RenameNode {
|
||||
command_name: "tree.node.rename",
|
||||
node_id,
|
||||
title,
|
||||
},
|
||||
)],
|
||||
)
|
||||
}
|
||||
PageTreeRuntimeAction::UpdateDropFeedback { feedback } => {
|
||||
let mut state = self.clone();
|
||||
state.drop_feedback = feedback;
|
||||
transition(state, [PageTreeRuntimeOutput::DomPatch])
|
||||
}
|
||||
PageTreeRuntimeAction::UpdateDropFeedbackForTarget {
|
||||
source_node_id,
|
||||
target_node_id,
|
||||
position,
|
||||
} => {
|
||||
let mut state = self.clone();
|
||||
state.drop_feedback =
|
||||
resolve_drop_feedback(env, &source_node_id, &target_node_id, position);
|
||||
transition(state, [PageTreeRuntimeOutput::DomPatch])
|
||||
}
|
||||
PageTreeRuntimeAction::DispatchMove {
|
||||
source_node_id,
|
||||
target_parent_id,
|
||||
position,
|
||||
} => transition(
|
||||
self.clone(),
|
||||
[PageTreeRuntimeOutput::CommandDispatch(
|
||||
PageTreeCommandDispatchEvent::MoveSubtree {
|
||||
command_name: "tree.subtree.move",
|
||||
source_node_id,
|
||||
target_node_id: None,
|
||||
target_parent_id,
|
||||
position,
|
||||
sort_order: None,
|
||||
},
|
||||
)],
|
||||
),
|
||||
PageTreeRuntimeAction::DispatchMoveToTarget {
|
||||
source_node_id,
|
||||
target_node_id,
|
||||
position,
|
||||
} => {
|
||||
let Some(resolved_drop) =
|
||||
resolve_drop_target(env, &source_node_id, &target_node_id, position)
|
||||
else {
|
||||
return transition(self.clone(), []);
|
||||
};
|
||||
transition(
|
||||
self.clone(),
|
||||
[PageTreeRuntimeOutput::CommandDispatch(
|
||||
PageTreeCommandDispatchEvent::MoveSubtree {
|
||||
command_name: "tree.subtree.move",
|
||||
source_node_id,
|
||||
target_node_id: Some(target_node_id),
|
||||
target_parent_id: resolved_drop.target_parent_id,
|
||||
position,
|
||||
sort_order: Some(resolved_drop.sort_order),
|
||||
},
|
||||
)],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_focus(&self, focused_id: Option<String>) -> PageTreeRuntimeTransition {
|
||||
let mut state = self.clone();
|
||||
state.focused_id = focused_id;
|
||||
transition(state, [PageTreeRuntimeOutput::DomPatch])
|
||||
}
|
||||
|
||||
fn with_expansion(
|
||||
&self,
|
||||
env: &PageTreeRuntimeEnvironment,
|
||||
node_id: String,
|
||||
expanded: bool,
|
||||
) -> PageTreeRuntimeTransition {
|
||||
let mut state = self.clone();
|
||||
if env.expandable_node_ids.contains(&node_id) {
|
||||
if expanded {
|
||||
state.expanded_ids.insert(node_id);
|
||||
} else {
|
||||
state.expanded_ids.remove(&node_id);
|
||||
}
|
||||
}
|
||||
transition(state, [PageTreeRuntimeOutput::DomPatch])
|
||||
}
|
||||
|
||||
fn with_focused_intent(&self, intent: PageTreeIntentEvent) -> PageTreeRuntimeTransition {
|
||||
let has_focus = !match &intent {
|
||||
PageTreeIntentEvent::Open { node_id } => node_id,
|
||||
PageTreeIntentEvent::ContextMenu { node_id } => node_id,
|
||||
}
|
||||
.is_empty();
|
||||
if has_focus {
|
||||
transition(self.clone(), [PageTreeRuntimeOutput::Intent(intent)])
|
||||
} else {
|
||||
transition(self.clone(), [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ResolvedPageDropTarget {
|
||||
target_parent_id: Option<String>,
|
||||
sort_order: i64,
|
||||
}
|
||||
|
||||
fn resolve_drop_feedback(
|
||||
env: &PageTreeRuntimeEnvironment,
|
||||
source_node_id: &str,
|
||||
target_node_id: &str,
|
||||
position: PageTreeDropPosition,
|
||||
) -> Option<PageTreeDropFeedback> {
|
||||
let resolved = resolve_drop_target(env, source_node_id, target_node_id, position)?;
|
||||
Some(PageTreeDropFeedback {
|
||||
source_node_id: source_node_id.to_string(),
|
||||
target_node_id: Some(target_node_id.to_string()),
|
||||
target_parent_id: resolved.target_parent_id,
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_drop_target(
|
||||
env: &PageTreeRuntimeEnvironment,
|
||||
source_node_id: &str,
|
||||
target_node_id: &str,
|
||||
position: PageTreeDropPosition,
|
||||
) -> Option<ResolvedPageDropTarget> {
|
||||
let source_node_id = source_node_id.trim();
|
||||
let target_node_id = target_node_id.trim();
|
||||
if source_node_id.is_empty() || target_node_id.is_empty() || source_node_id == target_node_id {
|
||||
return None;
|
||||
}
|
||||
let source_row = env.rows.iter().find(|row| row.node_id == source_node_id)?;
|
||||
let target_row = env.rows.iter().find(|row| row.node_id == target_node_id)?;
|
||||
if source_row.parent_node_id != target_row.parent_node_id {
|
||||
return None;
|
||||
}
|
||||
let mut siblings = env
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.parent_node_id == target_row.parent_node_id)
|
||||
.collect::<Vec<_>>();
|
||||
siblings.sort_by(|left, right| {
|
||||
left.position
|
||||
.cmp(&right.position)
|
||||
.then_with(|| left.node_id.cmp(&right.node_id))
|
||||
});
|
||||
let target_index = siblings
|
||||
.iter()
|
||||
.position(|row| row.node_id == target_row.node_id)? as i64;
|
||||
let sort_order = match position {
|
||||
PageTreeDropPosition::Before | PageTreeDropPosition::Inside => target_index,
|
||||
PageTreeDropPosition::After => target_index + 1,
|
||||
};
|
||||
Some(ResolvedPageDropTarget {
|
||||
target_parent_id: target_row.parent_node_id.clone(),
|
||||
sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
fn transition<const N: usize>(
|
||||
state: PageTreeRuntimeState,
|
||||
outputs: [PageTreeRuntimeOutput; N],
|
||||
) -> PageTreeRuntimeTransition {
|
||||
PageTreeRuntimeTransition {
|
||||
state,
|
||||
outputs: outputs.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
|
||||
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeRow,
|
||||
PageTreeRuntimeOutput, PageTreeRuntimeState,
|
||||
};
|
||||
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_tree_runtime_reducer_normalizes_focus_expansion_intents_and_drag_move() {
|
||||
let env = PageTreeRuntimeEnvironment {
|
||||
visible_node_ids: ids(&["doc:root", "doc:child", "doc:sibling"]),
|
||||
expandable_node_ids: ids(&["doc:root"]).into_iter().collect(),
|
||||
rows: vec![
|
||||
PageTreeRuntimeRow {
|
||||
node_id: "doc:root".into(),
|
||||
parent_node_id: None,
|
||||
position: 0,
|
||||
},
|
||||
PageTreeRuntimeRow {
|
||||
node_id: "doc:child".into(),
|
||||
parent_node_id: Some("doc:root".into()),
|
||||
position: 0,
|
||||
},
|
||||
PageTreeRuntimeRow {
|
||||
node_id: "doc:sibling".into(),
|
||||
parent_node_id: Some("doc:root".into()),
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
let state = PageTreeRuntimeState {
|
||||
focused_id: Some("doc:missing".into()),
|
||||
..PageTreeRuntimeState::default()
|
||||
};
|
||||
|
||||
let transition = state.reduce(&env, PageTreeRuntimeAction::Normalize);
|
||||
assert_eq!(transition.state.focused_id.as_deref(), Some("doc:root"));
|
||||
assert!(transition
|
||||
.outputs
|
||||
.contains(&PageTreeRuntimeOutput::DomPatch));
|
||||
|
||||
let transition = transition
|
||||
.state
|
||||
.reduce(&env, PageTreeRuntimeAction::MoveNext);
|
||||
assert_eq!(transition.state.focused_id.as_deref(), Some("doc:child"));
|
||||
|
||||
let transition = transition.state.reduce(
|
||||
&env,
|
||||
PageTreeRuntimeAction::Expand {
|
||||
node_id: "doc:root".into(),
|
||||
},
|
||||
);
|
||||
assert!(transition.state.expanded_ids.contains("doc:root"));
|
||||
assert!(transition
|
||||
.outputs
|
||||
.contains(&PageTreeRuntimeOutput::DomPatch));
|
||||
|
||||
let transition = transition
|
||||
.state
|
||||
.reduce(&env, PageTreeRuntimeAction::OpenFocused);
|
||||
assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent(
|
||||
PageTreeIntentEvent::Open {
|
||||
node_id: "doc:child".into(),
|
||||
},
|
||||
)));
|
||||
|
||||
let transition = transition
|
||||
.state
|
||||
.reduce(&env, PageTreeRuntimeAction::ContextMenuFocused);
|
||||
assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent(
|
||||
PageTreeIntentEvent::ContextMenu {
|
||||
node_id: "doc:child".into(),
|
||||
},
|
||||
)));
|
||||
|
||||
let transition = transition.state.reduce(
|
||||
&env,
|
||||
PageTreeRuntimeAction::UpdateDropFeedback {
|
||||
feedback: Some(PageTreeDropFeedback {
|
||||
source_node_id: "doc:child".into(),
|
||||
target_node_id: Some("doc:sibling".into()),
|
||||
target_parent_id: Some("doc:root".into()),
|
||||
position: PageTreeDropPosition::Inside,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
transition
|
||||
.state
|
||||
.drop_feedback
|
||||
.as_ref()
|
||||
.map(|feedback| feedback.position),
|
||||
Some(PageTreeDropPosition::Inside)
|
||||
);
|
||||
|
||||
let transition = transition.state.reduce(
|
||||
&env,
|
||||
PageTreeRuntimeAction::UpdateDropFeedbackForTarget {
|
||||
source_node_id: "doc:child".into(),
|
||||
target_node_id: "doc:sibling".into(),
|
||||
position: PageTreeDropPosition::Before,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
transition
|
||||
.state
|
||||
.drop_feedback
|
||||
.as_ref()
|
||||
.and_then(|feedback| feedback.target_node_id.as_deref()),
|
||||
Some("doc:sibling")
|
||||
);
|
||||
|
||||
let transition = transition.state.reduce(
|
||||
&env,
|
||||
PageTreeRuntimeAction::DispatchMove {
|
||||
source_node_id: "doc:child".into(),
|
||||
target_parent_id: Some("doc:root".into()),
|
||||
position: PageTreeDropPosition::After,
|
||||
},
|
||||
);
|
||||
assert!(transition
|
||||
.outputs
|
||||
.contains(&PageTreeRuntimeOutput::CommandDispatch(
|
||||
PageTreeCommandDispatchEvent::MoveSubtree {
|
||||
command_name: "tree.subtree.move",
|
||||
source_node_id: "doc:child".into(),
|
||||
target_node_id: None,
|
||||
target_parent_id: Some("doc:root".into()),
|
||||
position: PageTreeDropPosition::After,
|
||||
sort_order: None,
|
||||
},
|
||||
)));
|
||||
|
||||
let transition = transition.state.reduce(
|
||||
&env,
|
||||
PageTreeRuntimeAction::DispatchMoveToTarget {
|
||||
source_node_id: "doc:child".into(),
|
||||
target_node_id: "doc:sibling".into(),
|
||||
position: PageTreeDropPosition::Before,
|
||||
},
|
||||
);
|
||||
assert!(transition
|
||||
.outputs
|
||||
.contains(&PageTreeRuntimeOutput::CommandDispatch(
|
||||
PageTreeCommandDispatchEvent::MoveSubtree {
|
||||
command_name: "tree.subtree.move",
|
||||
source_node_id: "doc:child".into(),
|
||||
target_node_id: Some("doc:sibling".into()),
|
||||
target_parent_id: Some("doc:root".into()),
|
||||
position: PageTreeDropPosition::Before,
|
||||
sort_order: Some(1),
|
||||
},
|
||||
)));
|
||||
|
||||
let transition = transition.state.reduce(
|
||||
&env,
|
||||
PageTreeRuntimeAction::DispatchCreate {
|
||||
parent_node_id: Some("doc:root".into()),
|
||||
},
|
||||
);
|
||||
assert!(transition
|
||||
.outputs
|
||||
.contains(&PageTreeRuntimeOutput::CommandDispatch(
|
||||
PageTreeCommandDispatchEvent::CreateNode {
|
||||
command_name: "tree.node.create",
|
||||
parent_node_id: Some("doc:root".into()),
|
||||
},
|
||||
)));
|
||||
|
||||
let transition = transition.state.reduce(
|
||||
&env,
|
||||
PageTreeRuntimeAction::DispatchRename {
|
||||
node_id: "doc:child".into(),
|
||||
title: "新标题".into(),
|
||||
},
|
||||
);
|
||||
assert!(transition
|
||||
.outputs
|
||||
.contains(&PageTreeRuntimeOutput::CommandDispatch(
|
||||
PageTreeCommandDispatchEvent::RenameNode {
|
||||
command_name: "tree.node.rename",
|
||||
node_id: "doc:child".into(),
|
||||
title: "新标题".into(),
|
||||
},
|
||||
)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user