Files
mnote/rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs
T

790 lines
27 KiB
Rust

use super::drag_drop_state::{resolve_drag_effect, TreeShellDragEffect};
use super::filetree_selection::{FileTreeSelectionModifiers, FileTreeSelectionState};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeEnvironment {
pub visible_row_ids: Vec<String>,
pub rows: Vec<FileTreeRuntimeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeRow {
pub row_id: String,
pub row_kind: String,
pub document_id: Option<String>,
pub asset_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeState {
pub active_row_id: Option<String>,
pub selection: FileTreeSelectionState,
pub drag_row_ids: Vec<String>,
pub drag_effect: Option<TreeShellDragEffect>,
pub drop_target_row_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeRuntimeTransition {
pub state: FileTreeRuntimeState,
pub outputs: BTreeSet<FileTreeRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum FileTreeRuntimeAction {
SelectRow {
row_id: String,
modifiers: FileTreeSelectionModifiers,
},
SelectContextRow {
row_id: String,
},
NormalizeVisibleRows,
ClearSelection,
ResolveDragRows {
row_id: String,
has_external_files: bool,
alt_key: bool,
},
UpdateDropTarget {
row_id: Option<String>,
},
DispatchInternalDrop {
target_row_id: Option<String>,
row_ids: Vec<String>,
copy: bool,
},
DispatchExternalDrop {
target_row_id: Option<String>,
file_count: u32,
},
OpenRow {
row_id: String,
},
FocusNext,
FocusPrevious,
FocusFirst,
FocusLast,
OpenFocused,
ContextMenuFocused,
BeginRenameFocused,
DeleteSelection,
CopySelection,
CutSelection,
PasteIntoFocused,
Escape,
ContextMenuRow {
row_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeRuntimeOutput {
DomPatch,
Intent(FileTreeIntentEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeIntentEvent {
Open {
target: FileTreeOpenTarget,
},
ContextMenu {
row_id: String,
target: FileTreeOpenTarget,
},
KeyboardCommand {
command: FileTreeKeyboardCommand,
row_ids: Vec<String>,
target_row_id: Option<String>,
},
InternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
row_ids: Vec<String>,
copy: bool,
},
ExternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
file_count: u32,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FileTreeKeyboardCommand {
Rename,
Delete,
Copy,
Cut,
Paste,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum FileTreeOpenTarget {
Document {
document_id: String,
},
Index {
document_id: String,
},
AssetFolder {
document_id: String,
asset_id: String,
},
Asset {
document_id: String,
asset_id: String,
},
}
impl FileTreeRuntimeState {
pub fn reduce(
&self,
env: &FileTreeRuntimeEnvironment,
action: FileTreeRuntimeAction,
) -> FileTreeRuntimeTransition {
match action {
FileTreeRuntimeAction::SelectRow { row_id, modifiers } => {
let mut state = self.clone();
state.selection =
state
.selection
.select_row(&row_id, &env.visible_row_ids, modifiers);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::SelectContextRow { row_id } => {
let mut state = self.clone();
state.selection = state.selection.select_context_row(&row_id);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::NormalizeVisibleRows => {
let mut state = self.clone();
state.selection = state
.selection
.normalize_for_visible_rows(&env.visible_row_ids);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ClearSelection => {
let mut state = self.clone();
state.selection = state.selection.clear();
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ResolveDragRows {
row_id,
has_external_files,
alt_key,
} => {
let mut state = self.clone();
state.drag_row_ids = state
.selection
.resolve_drag_row_ids_for_visible_rows(&row_id, &env.visible_row_ids);
state.drag_effect = Some(resolve_drag_effect(has_external_files, alt_key));
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::UpdateDropTarget { row_id } => {
let mut state = self.clone();
state.drop_target_row_id = row_id;
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id,
row_ids,
copy,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
let row_ids = row_ids
.into_iter()
.filter(|row_id| !row_id.is_empty())
.collect::<Vec<_>>();
if row_ids.is_empty() {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop {
target_row_id,
target: Some(target),
row_ids,
copy,
}),
],
)
}
FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id,
file_count,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
if file_count == 0 {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ExternalDrop {
target_row_id,
target: Some(target),
file_count,
}),
],
)
}
FileTreeRuntimeAction::OpenRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
let mut state = self.clone();
state.active_row_id = Some(row_id);
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }),
],
)
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::FocusNext => transition(
focus_relative_row(self, env, 1),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusPrevious => transition(
focus_relative_row(self, env, -1),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusFirst => transition(
focus_absolute_row(self, env, 0),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusLast => transition(
focus_absolute_row(self, env, env.visible_row_ids.len().saturating_sub(1)),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::OpenFocused => {
if let Some(row_id) = self.selection.focused_row_id.clone() {
self.reduce(env, FileTreeRuntimeAction::OpenRow { row_id })
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::ContextMenuFocused => {
if let Some(row_id) = self.selection.focused_row_id.clone() {
self.reduce(env, FileTreeRuntimeAction::ContextMenuRow { row_id })
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::BeginRenameFocused => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Rename,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::DeleteSelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Delete,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::CopySelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Copy,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::CutSelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Cut,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::PasteIntoFocused => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Paste,
Vec::new(),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::Escape => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ContextMenuRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ContextMenu { row_id, target },
)],
)
} else {
transition(self.clone(), [])
}
}
}
}
}
fn focus_relative_row(
state: &FileTreeRuntimeState,
env: &FileTreeRuntimeEnvironment,
offset: isize,
) -> FileTreeRuntimeState {
if env.visible_row_ids.is_empty() {
return state.clone();
}
let current = state
.selection
.focused_row_id
.as_ref()
.and_then(|row_id| {
env.visible_row_ids
.iter()
.position(|candidate| candidate == row_id)
})
.unwrap_or(0);
let next = if offset.is_negative() {
current.saturating_sub(offset.unsigned_abs())
} else {
(current + offset as usize).min(env.visible_row_ids.len() - 1)
};
focus_absolute_row(state, env, next)
}
fn focus_absolute_row(
state: &FileTreeRuntimeState,
env: &FileTreeRuntimeEnvironment,
index: usize,
) -> FileTreeRuntimeState {
let mut next = state.clone();
next.selection.focused_row_id = env.visible_row_ids.get(index).cloned();
next
}
fn selected_or_focused_rows(state: &FileTreeRuntimeState) -> Vec<String> {
if !state.selection.selected_row_ids.is_empty() {
return state.selection.selected_row_ids.iter().cloned().collect();
}
state
.selection
.focused_row_id
.iter()
.cloned()
.collect::<Vec<_>>()
}
fn keyboard_command_transition(
state: &FileTreeRuntimeState,
command: FileTreeKeyboardCommand,
row_ids: Vec<String>,
target_row_id: Option<String>,
) -> FileTreeRuntimeTransition {
if row_ids.is_empty() && !matches!(command, FileTreeKeyboardCommand::Paste) {
return transition(state.clone(), []);
}
transition(
state.clone(),
[FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command,
row_ids,
target_row_id,
},
)],
)
}
fn resolve_open_target(
env: &FileTreeRuntimeEnvironment,
row_id: &str,
) -> Option<FileTreeOpenTarget> {
let row_by_id = env
.rows
.iter()
.map(|row| (row.row_id.as_str(), row))
.collect::<BTreeMap<_, _>>();
let row = row_by_id.get(row_id)?;
match row.row_kind.as_str() {
"doc" | "document" => Some(FileTreeOpenTarget::Document {
document_id: row.document_id.clone()?,
}),
"index" => Some(FileTreeOpenTarget::Index {
document_id: row.document_id.clone()?,
}),
"asset-folder" | "asset_folder" => Some(FileTreeOpenTarget::AssetFolder {
document_id: row.document_id.clone()?,
asset_id: row.asset_id.clone()?,
}),
"asset" => Some(FileTreeOpenTarget::Asset {
document_id: row.document_id.clone()?,
asset_id: row.asset_id.clone()?,
}),
_ => None,
}
}
fn transition<const N: usize>(
state: FileTreeRuntimeState,
outputs: [FileTreeRuntimeOutput; N],
) -> FileTreeRuntimeTransition {
FileTreeRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
FileTreeIntentEvent, FileTreeKeyboardCommand, FileTreeOpenTarget, FileTreeRuntimeAction,
FileTreeRuntimeEnvironment, FileTreeRuntimeOutput, FileTreeRuntimeRow,
FileTreeRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use crate::tree_shell::filetree_selection::FileTreeSelectionModifiers;
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn env() -> FileTreeRuntimeEnvironment {
FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "index:root".into(),
row_kind: "index".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset-folder:mind".into(),
row_kind: "asset-folder".into(),
document_id: Some("root".into()),
asset_id: Some("mind".into()),
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
}
}
#[test]
fn filetree_runtime_reducer_covers_selection_drag_drop_and_open_menu_intents() {
let transition = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::SelectRow {
row_id: "doc:root".into(),
modifiers: FileTreeSelectionModifiers::default(),
},
);
assert!(transition
.state
.selection
.selected_row_ids
.contains("doc:root"));
assert!(transition
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::SelectRow {
row_id: "asset:image".into(),
modifiers: FileTreeSelectionModifiers {
shift_key: true,
..FileTreeSelectionModifiers::default()
},
},
);
assert!(transition
.state
.selection
.selected_row_ids
.contains("index:root"));
assert!(transition
.state
.selection
.selected_row_ids
.contains("asset-folder:mind"));
assert!(transition
.state
.selection
.selected_row_ids
.contains("asset:image"));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::ResolveDragRows {
row_id: "asset:image".into(),
has_external_files: false,
alt_key: true,
},
);
assert_eq!(
transition.state.drag_row_ids,
ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"])
);
assert_eq!(
transition.state.drag_effect,
Some(TreeShellDragEffect::Copy)
);
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::UpdateDropTarget {
row_id: Some("asset-folder:mind".into()),
},
);
assert_eq!(
transition.state.drop_target_row_id.as_deref(),
Some("asset-folder:mind")
);
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "doc:root".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Document {
document_id: "root".into(),
},
},
)));
assert_eq!(transition.state.active_row_id.as_deref(), Some("doc:root"));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "index:root".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Index {
document_id: "root".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "asset-folder:mind".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::AssetFolder {
document_id: "root".into(),
asset_id: "mind".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::ContextMenuRow {
row_id: "asset:image".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ContextMenu {
row_id: "asset:image".into(),
target: FileTreeOpenTarget::Asset {
document_id: "root".into(),
asset_id: "image".into(),
},
},
)));
}
#[test]
fn filetree_runtime_dispatches_drop_intents_and_clears_drag_state() {
let state = FileTreeRuntimeState {
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("asset-folder:mind".into()),
..FileTreeRuntimeState::default()
};
let transition = state.reduce(
&env(),
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("asset-folder:mind".into()),
row_ids: ids(&["asset:image"]),
copy: true,
},
);
assert!(transition.state.drag_row_ids.is_empty());
assert_eq!(transition.state.drag_effect, None);
assert_eq!(transition.state.drop_target_row_id, None);
assert!(transition
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::InternalDrop {
target_row_id: Some("asset-folder:mind".into()),
target: Some(FileTreeOpenTarget::AssetFolder {
document_id: "root".into(),
asset_id: "mind".into(),
}),
row_ids: ids(&["asset:image"]),
copy: true,
},
)));
let transition = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id: Some("doc:root".into()),
file_count: 2,
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ExternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
file_count: 2,
},
)));
let rejected = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("missing:target".into()),
row_ids: ids(&["asset:image"]),
copy: false,
},
);
assert!(rejected.outputs.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(!rejected.outputs.iter().any(|output| matches!(
output,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
)));
}
#[test]
fn filetree_runtime_keyboard_contract_separates_active_selection_and_focus() {
let state = FileTreeRuntimeState {
active_row_id: Some("doc:root".into()),
selection: crate::tree_shell::filetree_selection::FileTreeSelectionState::from_selected(
&["doc:root".to_string()],
),
..FileTreeRuntimeState::default()
};
let focused = state.reduce(&env(), FileTreeRuntimeAction::FocusNext);
assert_eq!(focused.state.active_row_id.as_deref(), Some("doc:root"));
assert_eq!(
focused.state.selection.focused_row_id.as_deref(),
Some("index:root")
);
assert!(focused
.state
.selection
.selected_row_ids
.contains("doc:root"));
let opened = focused
.state
.reduce(&env(), FileTreeRuntimeAction::OpenFocused);
assert_eq!(opened.state.active_row_id.as_deref(), Some("index:root"));
assert!(opened.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Index {
document_id: "root".into(),
},
},
)));
let rename = focused
.state
.reduce(&env(), FileTreeRuntimeAction::BeginRenameFocused);
assert!(rename.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command: FileTreeKeyboardCommand::Rename,
row_ids: vec!["doc:root".into()],
target_row_id: Some("index:root".into()),
},
)));
let cut = focused
.state
.reduce(&env(), FileTreeRuntimeAction::CutSelection);
assert!(cut.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command: FileTreeKeyboardCommand::Cut,
row_ids: vec!["doc:root".into()],
target_row_id: Some("index:root".into()),
},
)));
let escaped = FileTreeRuntimeState {
drag_row_ids: vec!["doc:root".into()],
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("index:root".into()),
..focused.state
}
.reduce(&env(), FileTreeRuntimeAction::Escape);
assert!(escaped.state.drag_row_ids.is_empty());
assert_eq!(escaped.state.drag_effect, None);
assert_eq!(escaped.state.drop_target_row_id, None);
}
}