445 lines
14 KiB
Rust
445 lines
14 KiB
Rust
//! Ordinary work-only group assignment, with one combined semantic/geometry plan.
|
|
//! App retains authority over the complete typed selection and gesture lifetime.
|
|
use super::*;
|
|
use std::sync::Arc;
|
|
|
|
#[derive(Debug)]
|
|
struct Captured {
|
|
accepted: Project,
|
|
diagram: Id,
|
|
selected: BTreeMap<Id, Id>,
|
|
anchor: Id,
|
|
roots: Vec<super::Scope>,
|
|
placements: BTreeMap<Id, NodePlacement>,
|
|
leaves: BTreeMap<Id, Leaf>,
|
|
index: ViewIndex,
|
|
before_visible: ProjectedView,
|
|
before_expanded: ProjectedView,
|
|
}
|
|
|
|
/// Destination plans share one immutable accepted capture.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Scope(Arc<Captured>);
|
|
|
|
impl Scope {
|
|
pub fn project(&self) -> &Project {
|
|
&self.0.accepted
|
|
}
|
|
|
|
pub fn index(&self) -> &ViewIndex {
|
|
&self.0.index
|
|
}
|
|
|
|
pub fn diagram(&self) -> &Diagram {
|
|
&self.0.accepted.diagrams[&self.0.diagram]
|
|
}
|
|
|
|
pub fn selected(&self) -> &BTreeMap<Id, Id> {
|
|
&self.0.selected
|
|
}
|
|
|
|
pub fn affected(&self) -> &BTreeMap<Id, NodePlacement> {
|
|
&self.0.placements
|
|
}
|
|
|
|
pub fn anchor(&self) -> Id {
|
|
self.0.anchor
|
|
}
|
|
|
|
pub fn leaves(&self) -> &BTreeMap<Id, Leaf> {
|
|
&self.0.leaves
|
|
}
|
|
}
|
|
|
|
/// App must reject non-placement members before constructing this complete map.
|
|
/// Every original identity is checked before redundant work roots are removed.
|
|
pub fn capture(
|
|
p: &Project,
|
|
diagram: Id,
|
|
selected: &BTreeMap<Id, Id>,
|
|
anchor: Id,
|
|
) -> Result<Scope, Refusal> {
|
|
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
|
|
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
|
|
if d.projection != ProjectionKind::Allocation
|
|
|| selected.is_empty()
|
|
|| !selected.contains_key(&anchor)
|
|
{
|
|
return Err(Refusal::Ineligible(
|
|
"Assignment requires selected work placements and a selected anchor in an Allocation view."
|
|
.into(),
|
|
));
|
|
}
|
|
let index = ViewIndex::new(p);
|
|
let before_visible = projection::project_diagram_with_index(p, d, &index);
|
|
let visible: BTreeMap<_, _> = before_visible
|
|
.nodes
|
|
.iter()
|
|
.map(|node| (node.id, node.element))
|
|
.collect();
|
|
let mut work = BTreeMap::new();
|
|
for (placement, element) in selected {
|
|
let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?;
|
|
let record = p.elements.get(element).ok_or(Refusal::StaleScope)?;
|
|
if node.element != *element || visible.get(placement) != Some(element) {
|
|
return Err(Refusal::StaleScope);
|
|
}
|
|
if !record.kind().is_work() || record.layer() != d.layer {
|
|
return Err(Refusal::Ineligible(
|
|
"Assignment requires only activities or functions in this layer. Use Arrange only for a supported mixed selection."
|
|
.into(),
|
|
));
|
|
}
|
|
work.insert(*element, *placement);
|
|
}
|
|
|
|
let mut roots = Vec::new();
|
|
let mut placements = BTreeMap::new();
|
|
let mut leaves = BTreeMap::new();
|
|
for (element, placement) in &work {
|
|
let record = &p.elements[element];
|
|
let mut ancestor = record.parent;
|
|
let mut seen = BTreeSet::new();
|
|
let mut covered = false;
|
|
while let Some(id) = ancestor {
|
|
if !seen.insert(id) {
|
|
return Err(Refusal::StaleScope);
|
|
}
|
|
let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
|
|
if parent.kind() != record.kind() {
|
|
break;
|
|
}
|
|
if work.contains_key(&id) {
|
|
covered = true;
|
|
break;
|
|
}
|
|
ancestor = parent.parent;
|
|
}
|
|
if covered {
|
|
continue;
|
|
}
|
|
let root = capture_validated(p, diagram, *element, *placement)?;
|
|
if root.work_records.len() > 1 && root.uniform == Uniform::MixedOrUnresolved {
|
|
return Err(Refusal::MixedLayoutRequired);
|
|
}
|
|
for (id, leaf) in &root.leaves {
|
|
if leaves.insert(*id, leaf.clone()).is_some() {
|
|
return Err(Refusal::StaleScope);
|
|
}
|
|
}
|
|
placements.extend(root.placements.clone());
|
|
roots.push(root);
|
|
}
|
|
let expansion = geometry::expansion_ids(p, d, &index, &placements)?;
|
|
let before_expanded = geometry::expanded_view(p, d, &index, &expansion);
|
|
if roots
|
|
.iter()
|
|
.any(|root| root.work_records.len() > 1 && !coherent(root, &before_expanded))
|
|
{
|
|
return Err(Refusal::MixedLayoutRequired);
|
|
}
|
|
Ok(Scope(Arc::new(Captured {
|
|
accepted: p.clone(),
|
|
diagram,
|
|
selected: selected.clone(),
|
|
anchor,
|
|
roots,
|
|
placements,
|
|
leaves,
|
|
index,
|
|
before_visible,
|
|
before_expanded,
|
|
})))
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SemanticPlan {
|
|
scope: Scope,
|
|
destination: Destination,
|
|
candidate: Project,
|
|
index: ViewIndex,
|
|
commands: Vec<Command>,
|
|
changed: BTreeSet<Id>,
|
|
expanded_ids: BTreeSet<Id>,
|
|
label: String,
|
|
warnings: Vec<Issue>,
|
|
}
|
|
|
|
impl SemanticPlan {
|
|
pub fn scope(&self) -> &Scope {
|
|
&self.scope
|
|
}
|
|
|
|
pub fn project(&self) -> &Project {
|
|
&self.candidate
|
|
}
|
|
|
|
pub fn index(&self) -> &ViewIndex {
|
|
&self.index
|
|
}
|
|
|
|
pub fn destination(&self) -> Destination {
|
|
self.destination
|
|
}
|
|
|
|
pub fn label(&self) -> &str {
|
|
&self.label
|
|
}
|
|
|
|
pub fn warnings(&self) -> &[Issue] {
|
|
&self.warnings
|
|
}
|
|
|
|
pub fn changed_count(&self) -> usize {
|
|
self.changed.len()
|
|
}
|
|
|
|
/// Already effective at the performer, or already unassigned for Outside.
|
|
pub fn already_correct_count(&self) -> usize {
|
|
self.scope.leaves().len() - self.changed.len()
|
|
}
|
|
}
|
|
|
|
/// Build one destination candidate. Per-root decisions read accepted facts;
|
|
/// aliases and new identities are reserved in the combined expected Project.
|
|
pub fn plan_semantics(
|
|
scope: &Scope,
|
|
destination: Destination,
|
|
allocation_ids: &BTreeMap<Id, Id>,
|
|
) -> Result<SemanticPlan, Refusal> {
|
|
let accepted = scope.project();
|
|
match destination {
|
|
Destination::LayoutOnly => {
|
|
return Err(Refusal::Ineligible(
|
|
"Use the explicit Arrange only planner for presentation-only movement.".into(),
|
|
));
|
|
}
|
|
Destination::Performer(id) => {
|
|
if !accepted.elements.get(&id).is_some_and(|element| {
|
|
element.kind().is_performer() && element.layer() == scope.diagram().layer
|
|
}) {
|
|
return Err(Refusal::Ineligible(
|
|
"Choose a performer in this layer.".into(),
|
|
));
|
|
}
|
|
}
|
|
Destination::OutsidePerformers => {}
|
|
}
|
|
let mut expected = accepted.clone();
|
|
let mut edits = IntendedEdits::default();
|
|
for root in &scope.0.roots {
|
|
append_semantic_edits(
|
|
accepted,
|
|
root,
|
|
destination,
|
|
allocation_ids,
|
|
&mut expected,
|
|
&mut edits,
|
|
)?;
|
|
}
|
|
for diagram in expected.diagrams.values_mut() {
|
|
diagram
|
|
.routes
|
|
.retain(|route| !edits.deleted.contains(&route.edge));
|
|
}
|
|
let candidate = prepare_command(accepted, &Command::Batch(edits.commands.clone()))
|
|
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
|
.project;
|
|
exact_project(&expected, &candidate)?;
|
|
let warnings = validation::validate(&candidate)
|
|
.into_iter()
|
|
.filter(|issue| issue.severity == Severity::Warning)
|
|
.collect();
|
|
let index = ViewIndex::new(&candidate);
|
|
// Assignment may change visual ancestry: never reuse accepted expansion IDs.
|
|
let expanded_ids = geometry::expansion_ids(
|
|
&candidate,
|
|
&candidate.diagrams[&scope.0.diagram],
|
|
&index,
|
|
scope.affected(),
|
|
)?;
|
|
let total = scope.leaves().len();
|
|
let changed = edits.changed.len();
|
|
let already_correct = total - changed;
|
|
let noun = match (scope.diagram().layer, total) {
|
|
(Layer::Oa, 1) => "activity",
|
|
(Layer::Oa, _) => "activities",
|
|
(Layer::Sa, 1) => "function",
|
|
(Layer::Sa, _) => "functions",
|
|
};
|
|
let retained_roles = scope
|
|
.leaves()
|
|
.values()
|
|
.any(|leaf| !leaf.role_assignments.is_empty());
|
|
let label = match destination {
|
|
Destination::Performer(id) if changed == 0 => {
|
|
format!(
|
|
"Arrange within {} — all {total} leaf {noun} already assigned",
|
|
accepted.named(id)
|
|
)
|
|
}
|
|
Destination::Performer(id) => format!(
|
|
"Assign {changed} of {total} leaf {noun} to {}{}{}",
|
|
accepted.named(id),
|
|
if already_correct > 0 {
|
|
format!(" · {already_correct} already assigned")
|
|
} else {
|
|
String::new()
|
|
},
|
|
if retained_roles {
|
|
" — role responsibility retained"
|
|
} else {
|
|
""
|
|
},
|
|
),
|
|
Destination::OutsidePerformers if changed == 0 => {
|
|
"Arrange unassigned work — decomposition retained".into()
|
|
}
|
|
Destination::OutsidePerformers => format!(
|
|
"Remove assignments from {changed} of {total} leaf {noun} — decomposition retained"
|
|
),
|
|
Destination::LayoutOnly => unreachable!(),
|
|
};
|
|
Ok(SemanticPlan {
|
|
scope: scope.clone(),
|
|
destination,
|
|
candidate,
|
|
index,
|
|
commands: edits.commands,
|
|
changed: edits.changed,
|
|
expanded_ids,
|
|
label,
|
|
warnings,
|
|
})
|
|
}
|
|
|
|
/// Translate the entire captured Q once, then evaluate the combined candidate.
|
|
/// This path does not clone/validate a Project or rebuild semantic indexes.
|
|
pub fn place(
|
|
plan: &SemanticPlan,
|
|
requested: Position,
|
|
presentation: PresentationContext,
|
|
) -> Result<PlacementPlan, Refusal> {
|
|
if !requested.x.is_finite()
|
|
|| !requested.y.is_finite()
|
|
|| !presentation.viewport.valid()
|
|
|| !presentation.zoom.is_finite()
|
|
|| presentation.zoom <= 0.
|
|
{
|
|
return Err(Refusal::LayoutRequired(
|
|
"Make room or adjust the view, then drag again.".into(),
|
|
));
|
|
}
|
|
let scope = &plan.scope;
|
|
let geometry::Translation {
|
|
mut diagram,
|
|
mut commands,
|
|
delta,
|
|
} = geometry::translate(
|
|
scope.project(),
|
|
scope.diagram(),
|
|
&plan.candidate.diagrams[&scope.0.diagram],
|
|
scope.affected(),
|
|
scope.anchor(),
|
|
requested,
|
|
)?;
|
|
let mut unfolded = None;
|
|
if let Destination::Performer(performer) = plan.destination {
|
|
let target = presentation.target.ok_or_else(|| {
|
|
Refusal::LayoutRequired("A visible accepted performer target is required.".into())
|
|
})?;
|
|
if !scope
|
|
.0
|
|
.before_visible
|
|
.nodes
|
|
.iter()
|
|
.any(|node| node.id == target.placement && node.element == performer)
|
|
{
|
|
return Err(Refusal::LayoutRequired(
|
|
"Make room or adjust the view, then drag again.".into(),
|
|
));
|
|
}
|
|
let target_node = diagram
|
|
.nodes
|
|
.get_mut(&target.placement)
|
|
.ok_or(Refusal::StaleScope)?;
|
|
if target_node.element != performer {
|
|
return Err(Refusal::StaleScope);
|
|
}
|
|
if !plan.changed.is_empty() && target_node.collapsed {
|
|
target_node.collapsed = false;
|
|
unfolded = Some(target_node.id);
|
|
commands.push(Command::EditNode {
|
|
diagram: diagram.id,
|
|
original: target_node.id,
|
|
node: target_node.clone(),
|
|
});
|
|
}
|
|
} else if presentation.target.is_some() {
|
|
return Err(Refusal::Ineligible(
|
|
"An outside destination has no performer target.".into(),
|
|
));
|
|
}
|
|
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
|
|
let expanded =
|
|
geometry::expanded_view(&plan.candidate, &diagram, &plan.index, &plan.expanded_ids);
|
|
if scope
|
|
.0
|
|
.roots
|
|
.iter()
|
|
.any(|root| root.work_records.len() > 1 && !coherent(root, &expanded))
|
|
{
|
|
return Err(Refusal::MixedLayoutRequired);
|
|
}
|
|
geometry::check_scenes(
|
|
&scope.0.before_visible,
|
|
&scope.0.before_expanded,
|
|
&view,
|
|
&expanded,
|
|
)?;
|
|
geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?;
|
|
geometry::check_headers(&view, scope.selected(), presentation.viewport)?;
|
|
Ok(PlacementPlan {
|
|
diagram,
|
|
view,
|
|
delta,
|
|
unfolded,
|
|
requested,
|
|
presentation,
|
|
commands,
|
|
})
|
|
}
|
|
|
|
/// Recheck immutable facts and recompute geometry before one final source batch.
|
|
/// Zero delta remains a transaction when assignment semantics changed.
|
|
pub fn finish(
|
|
plan: &SemanticPlan,
|
|
live: &Project,
|
|
placement: &PlacementPlan,
|
|
) -> Result<Option<Command>, Refusal> {
|
|
if live != plan.scope.project() {
|
|
return Err(Refusal::StaleScope);
|
|
}
|
|
let checked = place(plan, placement.requested, placement.presentation)?;
|
|
if checked.diagram != placement.diagram
|
|
|| checked.view != placement.view
|
|
|| checked.delta != placement.delta
|
|
|| checked.unfolded != placement.unfolded
|
|
|| checked.commands != placement.commands
|
|
{
|
|
return Err(Refusal::StaleScope);
|
|
}
|
|
let mut expected = plan.candidate.clone();
|
|
expected
|
|
.diagrams
|
|
.insert(checked.diagram.id, checked.diagram);
|
|
let mut commands = plan.commands.clone();
|
|
commands.extend(checked.commands);
|
|
let command = Command::Batch(commands);
|
|
let actual = prepare_command(live, &command)
|
|
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
|
.project;
|
|
exact_project(&expected, &actual)?;
|
|
Ok((actual != *live).then_some(command))
|
|
}
|