Integrate allocation drag candidate with guarded input and atomic history
This commit is contained in:
@@ -0,0 +1,846 @@
|
||||
//! Pure, bounded allocation-drag composition. See the v2 interaction contract.
|
||||
//!
|
||||
//! These values are not input capabilities. The App must independently admit a
|
||||
//! clean press and retire its token on every document/input/camera lifetime
|
||||
//! change. Cache a SemanticPlan per token and destination; only `place` belongs
|
||||
//! on pointer motion. `finish` rechecks the live accepted Project and commands,
|
||||
//! but Document::execute still owns source admission and atomic history.
|
||||
use oa_core::{
|
||||
projection::{self, ProjectedNode, ProjectedView},
|
||||
validation::{self, Issue, Severity},
|
||||
view_options::ViewIndex,
|
||||
*,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Destination {
|
||||
LayoutOnly,
|
||||
Performer(Id),
|
||||
OutsidePerformers,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Uniform {
|
||||
Assigned(Id),
|
||||
Unassigned,
|
||||
MixedOrUnresolved,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Leaf {
|
||||
pub direct: Option<Relation>,
|
||||
pub role_assignments: Vec<Relation>,
|
||||
pub role_realizations: Vec<Relation>,
|
||||
pub effective_performer: Option<Id>,
|
||||
}
|
||||
|
||||
/// Exact semantic scope S, leaf scope L and active placement scope Q, including
|
||||
/// folded/unplaced leaves. Retaining records detects first-child responsibility
|
||||
/// transfer. Revision and input-epoch guards remain the caller's responsibility.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Scope {
|
||||
pub project: Id,
|
||||
pub work: Id,
|
||||
pub placement: Id,
|
||||
pub work_records: BTreeMap<Id, Element>,
|
||||
pub leaves: BTreeMap<Id, Leaf>,
|
||||
pub placements: BTreeMap<Id, NodePlacement>,
|
||||
pub diagram: Diagram,
|
||||
pub uniform: Uniform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChangedRecord {
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub category: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Refusal {
|
||||
Ineligible(String),
|
||||
StaleScope,
|
||||
MixedLayoutRequired,
|
||||
RoleExitRequired {
|
||||
work: Id,
|
||||
roles: Vec<Id>,
|
||||
performers: Vec<Id>,
|
||||
message: String,
|
||||
},
|
||||
MissingAllocationId(Id),
|
||||
InvalidCandidate(String),
|
||||
CollateralChangeRequired(Vec<ChangedRecord>),
|
||||
LayoutRequired(String),
|
||||
}
|
||||
|
||||
pub fn capture(p: &Project, diagram: Id, work: Id, placement: 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)?;
|
||||
let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?;
|
||||
if !root.kind().is_work()
|
||||
|| d.projection != ProjectionKind::Allocation
|
||||
|| d.layer != root.layer()
|
||||
{
|
||||
return Err(Refusal::Ineligible(
|
||||
"Select an activity or function in an Allocation view.".into(),
|
||||
));
|
||||
}
|
||||
if d.nodes.get(&placement).is_none_or(|n| n.element != work) {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let mut work_records = BTreeMap::new();
|
||||
let mut leaves = BTreeMap::new();
|
||||
let mut pending = vec![work];
|
||||
while let Some(id) = pending.pop() {
|
||||
let element = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
|
||||
if element.kind() != root.kind() || work_records.insert(id, element.clone()).is_some() {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let children: Vec<_> = p
|
||||
.children(id)
|
||||
.filter(|e| e.kind() == root.kind())
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
if !children.is_empty() {
|
||||
pending.extend(children);
|
||||
continue;
|
||||
}
|
||||
let direct: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Allocation && r.target() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
if direct.len() > 1 {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let role_assignments: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let roles: BTreeSet<_> = role_assignments.iter().map(Relation::source).collect();
|
||||
let role_realizations = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleRealization && roles.contains(&r.target()))
|
||||
.cloned()
|
||||
.collect();
|
||||
leaves.insert(
|
||||
id,
|
||||
Leaf {
|
||||
direct: direct.into_iter().next(),
|
||||
role_assignments,
|
||||
role_realizations,
|
||||
effective_performer: validation::effective_performer(p, id),
|
||||
},
|
||||
);
|
||||
}
|
||||
if leaves.is_empty() {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let uniform = if leaves
|
||||
.values()
|
||||
.all(|l| l.direct.is_none() && l.role_assignments.is_empty())
|
||||
{
|
||||
Uniform::Unassigned
|
||||
} else if let Some(performer) = leaves.values().next().and_then(|l| l.effective_performer)
|
||||
&& leaves
|
||||
.values()
|
||||
.all(|l| l.effective_performer == Some(performer))
|
||||
{
|
||||
Uniform::Assigned(performer)
|
||||
} else {
|
||||
Uniform::MixedOrUnresolved
|
||||
};
|
||||
let placements = d
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(_, n)| work_records.contains_key(&n.element))
|
||||
.map(|(id, n)| (*id, n.clone()))
|
||||
.collect();
|
||||
Ok(Scope {
|
||||
project: p.id,
|
||||
work,
|
||||
placement,
|
||||
work_records,
|
||||
leaves,
|
||||
placements,
|
||||
diagram: d.clone(),
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SemanticPlan {
|
||||
pub scope: Scope,
|
||||
pub destination: Destination,
|
||||
pub label: String,
|
||||
pub warnings: Vec<Issue>,
|
||||
accepted: Project,
|
||||
candidate: Project,
|
||||
index: ViewIndex,
|
||||
commands: Vec<Command>,
|
||||
before_visible: ProjectedView,
|
||||
before_expanded: ProjectedView,
|
||||
}
|
||||
|
||||
impl SemanticPlan {
|
||||
/// All preview drawing, ports and routing must use this candidate and index.
|
||||
pub fn project(&self) -> &Project {
|
||||
&self.candidate
|
||||
}
|
||||
pub fn index(&self) -> &ViewIndex {
|
||||
&self.index
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocation identities are allocated once by the gesture owner, keyed by leaf,
|
||||
/// and reused on target revisits and release. No random identity generation here.
|
||||
pub fn plan_semantics(
|
||||
p: &Project,
|
||||
scope: &Scope,
|
||||
destination: Destination,
|
||||
allocation_ids: &BTreeMap<Id, Id>,
|
||||
) -> Result<SemanticPlan, Refusal> {
|
||||
if &capture(p, scope.diagram.id, scope.work, scope.placement)? != scope {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
if scope.work_records.len() > 1 && scope.uniform == Uniform::MixedOrUnresolved {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
if let Destination::Performer(id) = destination {
|
||||
let valid = p
|
||||
.elements
|
||||
.get(&id)
|
||||
.is_some_and(|e| e.kind().is_performer() && e.layer() == scope.diagram.layer);
|
||||
if !valid {
|
||||
return Err(Refusal::Ineligible(
|
||||
"Choose a performer in this layer.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let before_index = ViewIndex::new(p);
|
||||
let before_visible = projection::project_diagram_with_index(p, &scope.diagram, &before_index);
|
||||
let before_expanded = expanded_view(p, &scope.diagram, scope, &before_index);
|
||||
if scope.work_records.len() > 1 && !coherent(scope, &before_expanded) {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
let same_performer =
|
||||
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
|
||||
let mut expected = p.clone();
|
||||
let mut commands = Vec::new();
|
||||
let mut deleted = BTreeSet::new();
|
||||
if destination != Destination::LayoutOnly && !same_performer {
|
||||
for (work, leaf) in &scope.leaves {
|
||||
match destination {
|
||||
Destination::Performer(performer) => {
|
||||
if let Some(existing) = &leaf.direct {
|
||||
if existing.source() != performer {
|
||||
let mut edited = existing.clone();
|
||||
edited.body = RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: *work,
|
||||
};
|
||||
expected.relations.insert(edited.id, edited.clone());
|
||||
commands.push(Command::EditRelation(edited.id, edited));
|
||||
}
|
||||
} else {
|
||||
let id = *allocation_ids
|
||||
.get(work)
|
||||
.ok_or(Refusal::MissingAllocationId(*work))?;
|
||||
if id.is_nil() || expected.contains(id) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"The gesture's allocation identity is already in use.".into(),
|
||||
));
|
||||
}
|
||||
let name = format!("{} performs {}", p.named(performer), p.named(*work));
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: expected.fresh_alias(&name, "allocation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body: RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: *work,
|
||||
},
|
||||
};
|
||||
expected.relations.insert(id, relation.clone());
|
||||
commands.push(Command::CreateRelation(relation));
|
||||
}
|
||||
}
|
||||
Destination::OutsidePerformers => {
|
||||
if !leaf.role_assignments.is_empty() {
|
||||
let roles: Vec<_> = leaf
|
||||
.role_assignments
|
||||
.iter()
|
||||
.map(Relation::source)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let performers: Vec<_> = leaf
|
||||
.role_realizations
|
||||
.iter()
|
||||
.map(Relation::source)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let message = if leaf.direct.is_some() && performers.len() == 1 {
|
||||
format!(
|
||||
"Removing direct assignment would place {} in {} through its role.",
|
||||
p.named(*work),
|
||||
p.named(performers[0])
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} retains role responsibility through {}{}; edit that responsibility before moving outside performers.",
|
||||
p.named(*work),
|
||||
roles
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
if performers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" ({})",
|
||||
performers
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
)
|
||||
};
|
||||
return Err(Refusal::RoleExitRequired {
|
||||
work: *work,
|
||||
roles,
|
||||
performers,
|
||||
message,
|
||||
});
|
||||
}
|
||||
if let Some(existing) = &leaf.direct {
|
||||
expected.relations.remove(&existing.id);
|
||||
deleted.insert(existing.id);
|
||||
commands.push(Command::Delete(existing.id));
|
||||
}
|
||||
}
|
||||
Destination::LayoutOnly => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A route cannot survive the intentional deletion of its own edge, even in
|
||||
// another diagram. No other saved-view cleanup is authorized by this drag.
|
||||
for diagram in expected.diagrams.values_mut() {
|
||||
diagram.routes.retain(|r| !deleted.contains(&r.edge));
|
||||
}
|
||||
let actual = prepare_command(p, &Command::Batch(commands.clone()))
|
||||
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||
.project;
|
||||
exact_project(&expected, &actual)?;
|
||||
let retained_roles = scope
|
||||
.leaves
|
||||
.values()
|
||||
.any(|l| !l.role_assignments.is_empty());
|
||||
let label = match destination {
|
||||
Destination::LayoutOnly => "Arrange only".into(),
|
||||
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
|
||||
Destination::Performer(id) => format!(
|
||||
"Assign {} leaf {} to {}{}",
|
||||
scope.leaves.len(),
|
||||
match (scope.diagram.layer, scope.leaves.len()) {
|
||||
(Layer::Oa, 1) => "activity",
|
||||
(Layer::Oa, _) => "activities",
|
||||
(Layer::Sa, 1) => "function",
|
||||
(Layer::Sa, _) => "functions",
|
||||
},
|
||||
p.named(id),
|
||||
if retained_roles {
|
||||
" — explicit assignment; role responsibility retained"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
Destination::OutsidePerformers if commands.is_empty() => {
|
||||
"Arrange unassigned work — decomposition retained".into()
|
||||
}
|
||||
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
|
||||
};
|
||||
let warnings = validation::validate(&actual)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Warning)
|
||||
.collect();
|
||||
let index = ViewIndex::new(&actual);
|
||||
Ok(SemanticPlan {
|
||||
scope: scope.clone(),
|
||||
destination,
|
||||
label,
|
||||
warnings,
|
||||
accepted: p.clone(),
|
||||
candidate: actual,
|
||||
index,
|
||||
commands,
|
||||
before_visible,
|
||||
before_expanded,
|
||||
})
|
||||
}
|
||||
|
||||
/// World-space rectangle in the gesture's frozen transform. The UI resolver
|
||||
/// owns half-open pointer hits, semantic target precedence and occlusion.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
fn valid(self) -> bool {
|
||||
[
|
||||
self.position.x,
|
||||
self.position.y,
|
||||
self.size.width,
|
||||
self.size.height,
|
||||
]
|
||||
.iter()
|
||||
.all(|n| n.is_finite() && n.abs() <= MAX_COORDINATE)
|
||||
&& self.size.width > 0.
|
||||
&& self.size.height > 0.
|
||||
}
|
||||
fn contains(self, other: Self) -> bool {
|
||||
other.position.x >= self.position.x
|
||||
&& other.position.y >= self.position.y
|
||||
&& other.position.x + other.size.width <= self.position.x + self.size.width
|
||||
&& other.position.y + other.size.height <= self.position.y + self.size.height
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TargetPresentation {
|
||||
pub placement: Id,
|
||||
/// Exact frozen painted label bounds, supplied by the renderer/resolver.
|
||||
pub label: Rect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PresentationContext {
|
||||
/// Visible world rectangle derived from the actual captured pan/zoom/bounds.
|
||||
pub viewport: Rect,
|
||||
pub zoom: f32,
|
||||
pub target: Option<TargetPresentation>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlacementPlan {
|
||||
pub diagram: Diagram,
|
||||
pub view: ProjectedView,
|
||||
pub delta: Position,
|
||||
pub unfolded: Option<Id>,
|
||||
requested: Position,
|
||||
presentation: PresentationContext,
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
|
||||
/// `requested` is the original selected placement position plus pointer motion
|
||||
/// in the frozen transform (therefore preserving the press offset). Snap only
|
||||
/// this root; apply the resulting identical delta to captured Q and its routes.
|
||||
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
|
||||
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 root = &plan.scope.placements[&plan.scope.placement];
|
||||
let delta = Position {
|
||||
x: (requested.x / 10.).round() * 10. - root.position.x,
|
||||
y: (requested.y / 10.).round() * 10. - root.position.y,
|
||||
};
|
||||
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
|
||||
let mut commands = Vec::new();
|
||||
let mut deltas = BTreeMap::new();
|
||||
for (id, original) in &plan.scope.placements {
|
||||
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
|
||||
node.position = Position {
|
||||
x: original.position.x + delta.x,
|
||||
y: original.position.y + delta.y,
|
||||
};
|
||||
if !(Rect {
|
||||
position: node.position,
|
||||
size: node.size,
|
||||
})
|
||||
.valid()
|
||||
{
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"Moved coordinates exceed the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
deltas.insert(*id, delta);
|
||||
if node != original {
|
||||
commands.push(Command::EditNode {
|
||||
diagram: diagram.id,
|
||||
original: *id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Resolve endpoints using the captured accepted semantics, not a newly
|
||||
// regrouped descendant walk. Own-edge deletions were removed above already.
|
||||
for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) {
|
||||
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
if route.waypoints.iter().any(|p| {
|
||||
!p.x.is_finite()
|
||||
|| !p.y.is_finite()
|
||||
|| p.x.abs() > MAX_COORDINATE
|
||||
|| p.y.abs() > MAX_COORDINATE
|
||||
}) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"Moved route exceeds the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
*existing = route.clone();
|
||||
commands.push(Command::SetEdgeRoute {
|
||||
diagram: diagram.id,
|
||||
route,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut unfolded = None;
|
||||
if let Destination::Performer(performer) = plan.destination {
|
||||
let target = presentation.target.ok_or_else(|| {
|
||||
Refusal::LayoutRequired("The frozen performer label must be visible.".into())
|
||||
})?;
|
||||
let node = diagram
|
||||
.nodes
|
||||
.get_mut(&target.placement)
|
||||
.ok_or(Refusal::StaleScope)?;
|
||||
let frozen_header = plan
|
||||
.before_visible
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == target.placement)
|
||||
.map(|n| Rect {
|
||||
position: n.position,
|
||||
size: Size {
|
||||
width: n.size.width,
|
||||
height: n.size.height.min(62.),
|
||||
},
|
||||
});
|
||||
if node.element != performer
|
||||
|| frozen_header.is_none_or(|header| !header.contains(target.label))
|
||||
|| !target.label.valid()
|
||||
|| !presentation.viewport.contains(target.label)
|
||||
{
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
if node.collapsed && !plan.commands.is_empty() {
|
||||
node.collapsed = false;
|
||||
unfolded = Some(node.id);
|
||||
commands.push(Command::EditNode {
|
||||
diagram: diagram.id,
|
||||
original: node.id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
} else if presentation.target.is_some() {
|
||||
return Err(Refusal::Ineligible(
|
||||
"This layout/outside destination has no performer target.".into(),
|
||||
));
|
||||
}
|
||||
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
|
||||
let expanded = expanded_view(&plan.candidate, &diagram, &plan.scope, &plan.index);
|
||||
check_geometry(plan, &view, &expanded, delta)?;
|
||||
let selected = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == plan.scope.placement)
|
||||
.ok_or_else(|| {
|
||||
Refusal::LayoutRequired("The selected work header would be hidden.".into())
|
||||
})?;
|
||||
let header = Rect {
|
||||
position: selected.position,
|
||||
size: Size {
|
||||
width: selected.size.width,
|
||||
height: selected.size.height.min(62.),
|
||||
},
|
||||
};
|
||||
if !presentation.viewport.contains(header) {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
delta,
|
||||
unfolded,
|
||||
requested,
|
||||
presentation,
|
||||
commands,
|
||||
})
|
||||
}
|
||||
|
||||
/// Final semantic/presentation equality gate, after the App has checked its
|
||||
/// current token, owner, revision, clean drafts, frozen target and transform.
|
||||
/// Recompute geometry to avoid accepting a modified/stale placement result.
|
||||
/// None means a snapped no-op: the caller must not execute or add history.
|
||||
pub fn finish(
|
||||
plan: &SemanticPlan,
|
||||
live: &Project,
|
||||
placement: &PlacementPlan,
|
||||
) -> Result<Option<Command>, Refusal> {
|
||||
if live != &plan.accepted {
|
||||
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))
|
||||
}
|
||||
|
||||
fn expanded_view(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
scope: &Scope,
|
||||
index: &ViewIndex,
|
||||
) -> ProjectedView {
|
||||
let mut expanded = diagram.clone();
|
||||
for id in scope.placements.keys() {
|
||||
if let Some(node) = expanded.nodes.get_mut(id) {
|
||||
node.collapsed = false;
|
||||
}
|
||||
}
|
||||
projection::project_diagram_with_index(p, &expanded, index)
|
||||
}
|
||||
|
||||
fn ancestors(view: &ProjectedView) -> BTreeMap<Id, BTreeSet<Id>> {
|
||||
let parents: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n.visual_parent)).collect();
|
||||
parents
|
||||
.keys()
|
||||
.map(|node| {
|
||||
let mut current = parents[node];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
(*node, seen)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn coherent(scope: &Scope, view: &ProjectedView) -> bool {
|
||||
let ancestors = ancestors(view);
|
||||
scope.placements.keys().all(|id| {
|
||||
ancestors
|
||||
.get(id)
|
||||
.is_some_and(|parents| *id == scope.placement || parents.contains(&scope.placement))
|
||||
})
|
||||
}
|
||||
|
||||
fn overlap(a: &ProjectedNode, b: &ProjectedNode) -> f32 {
|
||||
let width = (a.position.x + a.size.width).min(b.position.x + b.size.width)
|
||||
- a.position.x.max(b.position.x);
|
||||
let height = (a.position.y + a.size.height).min(b.position.y + b.size.height)
|
||||
- a.position.y.max(b.position.y);
|
||||
width.max(0.) * height.max(0.)
|
||||
}
|
||||
|
||||
fn overlaps(before: &ProjectedView, after: &ProjectedView) -> bool {
|
||||
// Build geometry ancestry once, avoiding a repeated descendant walk for
|
||||
// every pair during pointer motion. Pairwise collision checking is bounded
|
||||
// to these two projected scenes and does not traverse/clone model semantics.
|
||||
let before_ancestors = ancestors(before);
|
||||
let after_ancestors = ancestors(after);
|
||||
let prior_nodes: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect();
|
||||
for (i, a) in after.nodes.iter().enumerate() {
|
||||
for b in &after.nodes[i + 1..] {
|
||||
if after_ancestors[&a.id].contains(&b.id) || after_ancestors[&b.id].contains(&a.id) {
|
||||
continue;
|
||||
}
|
||||
let prior = match (prior_nodes.get(&a.id), prior_nodes.get(&b.id)) {
|
||||
(Some(x), Some(y))
|
||||
if !before_ancestors[&x.id].contains(&y.id)
|
||||
&& !before_ancestors[&y.id].contains(&x.id) =>
|
||||
{
|
||||
overlap(x, y)
|
||||
}
|
||||
_ => 0.,
|
||||
};
|
||||
if overlap(a, b) > prior + 0.01 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_geometry(
|
||||
plan: &SemanticPlan,
|
||||
visible: &ProjectedView,
|
||||
expanded: &ProjectedView,
|
||||
delta: Position,
|
||||
) -> Result<(), Refusal> {
|
||||
if visible.nodes.iter().chain(&expanded.nodes).any(|n| {
|
||||
!(Rect {
|
||||
position: n.position,
|
||||
size: n.size,
|
||||
})
|
||||
.valid()
|
||||
}) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"The projected enclosure exceeds the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
if plan.scope.work_records.len() > 1 {
|
||||
if !coherent(&plan.scope, expanded) {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
for id in plan.scope.placements.keys() {
|
||||
let old = plan
|
||||
.before_expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *id)
|
||||
.ok_or(Refusal::MixedLayoutRequired)?;
|
||||
let new = expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *id)
|
||||
.ok_or(Refusal::MixedLayoutRequired)?;
|
||||
if (new.position.x - old.position.x - delta.x).abs() > 0.01
|
||||
|| (new.position.y - old.position.y - delta.y).abs() > 0.01
|
||||
|| (new.size.width - old.size.width).abs() > 0.01
|
||||
|| (new.size.height - old.size.height).abs() > 0.01
|
||||
{
|
||||
return Err(Refusal::LayoutRequired("This work subtree requires an explicit layout; its footprint cannot be translated unchanged.".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlaps(&plan.before_visible, visible) || overlaps(&plan.before_expanded, expanded) {
|
||||
return Err(Refusal::LayoutRequired("Make room before moving work: the candidate would introduce or enlarge overlapping cards.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare complete records, not just removed IDs. Deleting an Allocation can
|
||||
/// rewrite a surviving Constraint, remove artifacts/traces or prune view options.
|
||||
fn exact_project(expected: &Project, actual: &Project) -> Result<(), Refusal> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let mut records = Vec::new();
|
||||
fn changes<T: PartialEq>(
|
||||
expected: &BTreeMap<Id, T>,
|
||||
actual: &BTreeMap<Id, T>,
|
||||
category: &'static str,
|
||||
project: &Project,
|
||||
other: &Project,
|
||||
records: &mut Vec<ChangedRecord>,
|
||||
) {
|
||||
let ids: BTreeSet<_> = expected.keys().chain(actual.keys()).copied().collect();
|
||||
for id in ids {
|
||||
if expected.get(&id) != actual.get(&id) {
|
||||
records.push(ChangedRecord {
|
||||
id,
|
||||
name: if project.contains(id) {
|
||||
project.named(id)
|
||||
} else {
|
||||
other.named(id)
|
||||
}
|
||||
.into(),
|
||||
category,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
changes(
|
||||
&expected.elements,
|
||||
&actual.elements,
|
||||
"element",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.relations,
|
||||
&actual.relations,
|
||||
"relation",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.diagrams,
|
||||
&actual.diagrams,
|
||||
"diagram",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.artifact_links,
|
||||
&actual.artifact_links,
|
||||
"artifact",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.traces,
|
||||
&actual.traces,
|
||||
"trace",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
if expected.schema_version != actual.schema_version
|
||||
|| expected.id != actual.id
|
||||
|| expected.alias != actual.alias
|
||||
|| expected.name != actual.name
|
||||
|| expected.description != actual.description
|
||||
{
|
||||
records.push(ChangedRecord {
|
||||
id: expected.id,
|
||||
name: expected.name.clone(),
|
||||
category: "project",
|
||||
});
|
||||
}
|
||||
Err(Refusal::CollateralChangeRequired(records))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
//! A narrow dispatch boundary for Allocation work gestures. Iced rows visit
|
||||
//! siblings after capture; refusing inside Canvas alone still blurs an inspector
|
||||
//! field. Refuse before dispatching that press to any ordinary child instead.
|
||||
use crate::{
|
||||
app::{
|
||||
App, Message,
|
||||
allocation_drag::{self, Event as WorkEvent},
|
||||
},
|
||||
diagram::DiagramCanvas,
|
||||
};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Id, Operation, Tree, tree},
|
||||
};
|
||||
use iced::{
|
||||
Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse,
|
||||
};
|
||||
|
||||
pub const CANVAS_ID: &str = "allocation-work-canvas";
|
||||
pub const ARRANGE_SHORTCUT: &str = "Alt+A";
|
||||
|
||||
struct Root<'a> {
|
||||
app: &'a App,
|
||||
content: Element<'a, Message>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
pointer: Option<Point>,
|
||||
refused_left: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct CanvasBounds(Option<Rectangle>);
|
||||
impl Operation for CanvasBounds {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
|
||||
if id == Some(&Id::new(CANVAS_ID)) {
|
||||
self.0 = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn root<'a>(app: &'a App, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
Element::new(Root {
|
||||
app,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
fn arrange_key(event: &Event) -> bool {
|
||||
matches!(event, Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(key), modifiers, repeat: false, ..
|
||||
}) if key.eq_ignore_ascii_case("a") && *modifiers == keyboard::Modifiers::ALT)
|
||||
}
|
||||
impl Root<'_> {
|
||||
fn dirty_object(&self, bounds: Rectangle, screen: Point) -> bool {
|
||||
let Some(diagram) = self.app.active_diagram else {
|
||||
return false;
|
||||
};
|
||||
if !allocation_drag::contains(bounds, screen) {
|
||||
return false;
|
||||
}
|
||||
let canvas = DiagramCanvas {
|
||||
allocation: Some(self.app.allocation_capability()),
|
||||
project: self.app.project(),
|
||||
view_index: Some(self.app.document.view_index()),
|
||||
diagram,
|
||||
selected: self.app.selected,
|
||||
zoom: self.app.zoom,
|
||||
pan: self.app.pan,
|
||||
context: self.app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: false,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
canvas.work_dirty_point(bounds, screen)
|
||||
}
|
||||
}
|
||||
impl Widget<Message, Theme, Renderer> for Root<'_> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State::default())
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => state.pointer = Some(*position),
|
||||
Event::Mouse(mouse::Event::CursorLeft) => state.pointer = None,
|
||||
Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position()
|
||||
}
|
||||
Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.pointer = None;
|
||||
state.refused_left = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if state.refused_left {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
) {
|
||||
state.refused_left = false;
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Mouse(mouse::Event::CursorMoved { .. })) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.app.allocation_input_owner() && !shell.is_event_captured() {
|
||||
if arrange_key(event) {
|
||||
// App rechecks clean ownership before arming. A dirty field
|
||||
// retains its actual widget focus and selection on refusal.
|
||||
shell.publish(Message::WorkDrag(WorkEvent::ArrangeNext));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if self.app.allocation_drag.arrange_next
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
shell.publish(Message::Escape);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if !self.app.allocation_drag_clean()
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
)
|
||||
&& let Some(screen) = state.pointer.or_else(|| cursor.position())
|
||||
{
|
||||
let mut canvas = CanvasBounds::default();
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
&mut canvas,
|
||||
);
|
||||
if canvas
|
||||
.0
|
||||
.is_some_and(|bounds| self.dirty_object(bounds, screen))
|
||||
{
|
||||
state.refused_left = true;
|
||||
shell.publish(Message::WorkDrag(WorkEvent::Refused(
|
||||
self.app.allocation_drag.epoch,
|
||||
allocation_drag::FINISH_EDIT.into(),
|
||||
)));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod allocation_drag;
|
||||
pub(crate) mod architecture_authoring;
|
||||
#[cfg(test)]
|
||||
mod architecture_authoring_tests;
|
||||
@@ -327,6 +328,7 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
WorkDrag(allocation_drag::Event),
|
||||
BehaviorControl(behavior::canvas_workspace::Intent),
|
||||
Architecture(architecture_authoring::Event),
|
||||
Home(crate::home::Event),
|
||||
@@ -450,6 +452,7 @@ pub enum Message {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub allocation_drag: allocation_drag::State,
|
||||
pub home: crate::home::State,
|
||||
pub recovery: crate::recovery::State,
|
||||
recent_target: Option<PathBuf>,
|
||||
@@ -557,6 +560,7 @@ impl App {
|
||||
let explorer_index = crate::explorer::Index::new(&project, layer);
|
||||
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
|
||||
let mut app = Self {
|
||||
allocation_drag: Default::default(),
|
||||
home: Default::default(),
|
||||
recovery: Default::default(),
|
||||
recent_target: None,
|
||||
@@ -1060,11 +1064,21 @@ impl App {
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
architecture_authoring::subscription(self),
|
||||
iced::event::listen_with(|event, _, _| {
|
||||
matches!(
|
||||
event,
|
||||
iced::Event::Keyboard(keyboard::Event::KeyPressed { .. })
|
||||
)
|
||||
.then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary))
|
||||
}),
|
||||
behavior::creation::subscription(
|
||||
self.behavior.creation.has_pending() || self.behavior.source_active(),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
if matches!(event, iced::Event::Window(window::Event::Unfocused)) {
|
||||
return Some(Message::WorkDrag(allocation_drag::Event::Unfocused));
|
||||
}
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modifiers: mods,
|
||||
@@ -1108,6 +1122,11 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
self.allocation_before(&message);
|
||||
if let Message::WorkDrag(event) = message {
|
||||
return self.allocation_event(event);
|
||||
}
|
||||
let allocation_before = self.allocation_boundary();
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
return self.behavior_control(intent);
|
||||
}
|
||||
@@ -1118,6 +1137,9 @@ impl App {
|
||||
);
|
||||
let before = self.camera_boundary();
|
||||
let task = self.update_inner(message);
|
||||
if self.allocation_boundary() != allocation_before {
|
||||
self.allocation_retire();
|
||||
}
|
||||
if !camera && self.camera_boundary() != before {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
@@ -1516,6 +1538,7 @@ impl App {
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::WorkDrag(event) => return self.allocation_event(event),
|
||||
Message::BehaviorControl(intent) => return self.behavior_control(intent),
|
||||
Message::Architecture(event) => return self.architecture_event(event),
|
||||
Message::Home(event) => return self.home_event(event),
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Allocation work-body input admission. Gesture authority lives here, while
|
||||
//! the canvas retains ordered native pointer positions and frozen hit geometry.
|
||||
use super::{App, Message, Page};
|
||||
use crate::{allocation_drag as plan, route_edit::Context};
|
||||
use iced::{Point, Rectangle, Task, Vector};
|
||||
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Stamp {
|
||||
pub context: Context,
|
||||
pub epoch: u64,
|
||||
pub snapshot: usize,
|
||||
pub bounds: Rectangle,
|
||||
pub pan: Vector,
|
||||
pub zoom: f32,
|
||||
}
|
||||
impl Stamp {
|
||||
pub fn world(self, screen: Point) -> Point {
|
||||
Point::new(
|
||||
(screen.x - self.bounds.x - self.pan.x) / self.zoom,
|
||||
(screen.y - self.bounds.y - self.pan.y) / self.zoom,
|
||||
)
|
||||
}
|
||||
pub fn viewport(self) -> plan::Rect {
|
||||
let p = self.world(Point::new(self.bounds.x, self.bounds.y));
|
||||
plan::Rect {
|
||||
position: Position { x: p.x, y: p.y },
|
||||
size: Size {
|
||||
width: self.bounds.width / self.zoom,
|
||||
height: self.bounds.height / self.zoom,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Target {
|
||||
pub element: Id,
|
||||
pub placement: Id,
|
||||
pub region: Rectangle,
|
||||
pub label: plan::Rect,
|
||||
pub name: String,
|
||||
pub ancestors: Vec<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frozen {
|
||||
pub targets: Vec<Target>,
|
||||
cards: Vec<Rectangle>,
|
||||
controls: Vec<Rectangle>,
|
||||
edges: Vec<(Point, Point)>,
|
||||
radius: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Hit {
|
||||
Destination(plan::Destination, Option<usize>),
|
||||
OutsideViewport,
|
||||
Occluded,
|
||||
Ambiguous(Vec<usize>),
|
||||
}
|
||||
pub fn contains(r: Rectangle, p: Point) -> bool {
|
||||
p.x >= r.x && p.y >= r.y && p.x < r.x + r.width && p.y < r.y + r.height
|
||||
}
|
||||
impl Frozen {
|
||||
pub fn capture(
|
||||
project: &Project,
|
||||
scope: &plan::Scope,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
zoom: f32,
|
||||
) -> Self {
|
||||
let mut targets = Vec::new();
|
||||
let mut cards = Vec::new();
|
||||
for n in &view.nodes {
|
||||
if scope.placements.contains_key(&n.id) {
|
||||
continue;
|
||||
}
|
||||
let region = Rectangle::new(
|
||||
Point::new(n.position.x, n.position.y),
|
||||
iced::Size::new(n.size.width, n.size.height),
|
||||
);
|
||||
let Some(e) = project.elements.get(&n.element) else {
|
||||
cards.push(region);
|
||||
continue;
|
||||
};
|
||||
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
|
||||
let mut ancestors = Vec::new();
|
||||
let mut parent = e.parent;
|
||||
while let Some(id) = parent {
|
||||
if ancestors.contains(&id) {
|
||||
break;
|
||||
}
|
||||
ancestors.push(id);
|
||||
parent = project.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
// This is also the exact frozen label painted above the ghost.
|
||||
let name = crate::views::short(
|
||||
project.named(e.id),
|
||||
(((region.width - 28.).max(10.) / 10.) as usize).min(34),
|
||||
);
|
||||
let label = plan::Rect {
|
||||
position: Position {
|
||||
x: region.x + 10.,
|
||||
y: region.y + 31.,
|
||||
},
|
||||
size: Size {
|
||||
width: (name.chars().count() as f32 * 10. + 8.)
|
||||
.min((region.width - 20.).max(1.)),
|
||||
height: 22.,
|
||||
},
|
||||
};
|
||||
let collapsed = scope.diagram.nodes[&n.id].collapsed;
|
||||
targets.push(Target {
|
||||
element: e.id,
|
||||
placement: n.id,
|
||||
region: if collapsed {
|
||||
Rectangle {
|
||||
height: region.height.min(62.),
|
||||
..region
|
||||
}
|
||||
} else {
|
||||
region
|
||||
},
|
||||
label,
|
||||
name,
|
||||
ancestors,
|
||||
});
|
||||
} else {
|
||||
cards.push(region);
|
||||
}
|
||||
}
|
||||
let radius = (10. / zoom).max(10.);
|
||||
let mut controls: Vec<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|port| !scope.placements.contains_key(&port.owner))
|
||||
.map(|port| {
|
||||
Rectangle::new(
|
||||
Point::new(port.position.x - radius, port.position.y - radius),
|
||||
iced::Size::new(radius * 2., radius * 2.),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut edges = Vec::new();
|
||||
for edge in routed.edges.values() {
|
||||
if let Some(relation) = project.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, edge) {
|
||||
if !end.label.text.is_empty() {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(end.label.bounds.position.x, end.label.bounds.position.y),
|
||||
iced::Size::new(
|
||||
end.label.bounds.size.width,
|
||||
end.label.bounds.size.height,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !edge.label.text.is_empty() {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(edge.label.bounds.position.x, edge.label.bounds.position.y),
|
||||
iced::Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
|
||||
));
|
||||
}
|
||||
for pin in &edge.pins {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(pin.x - 8. / zoom, pin.y - 8. / zoom),
|
||||
iced::Size::new(16. / zoom, 16. / zoom),
|
||||
));
|
||||
}
|
||||
for points in edge.points.windows(2) {
|
||||
edges.push((
|
||||
Point::new(points[0].x, points[0].y),
|
||||
Point::new(points[1].x, points[1].y),
|
||||
));
|
||||
}
|
||||
}
|
||||
Self {
|
||||
targets,
|
||||
cards,
|
||||
controls,
|
||||
edges,
|
||||
radius: 6. / zoom,
|
||||
}
|
||||
}
|
||||
pub fn resolve(&self, stamp: Stamp, screen: Option<Point>, arrange: bool) -> Hit {
|
||||
let Some(screen) = screen.filter(|p| contains(stamp.bounds, *p)) else {
|
||||
return Hit::OutsideViewport;
|
||||
};
|
||||
let p = stamp.world(screen);
|
||||
// Drag status lives in the toolbar, outside this actual canvas viewport.
|
||||
if self
|
||||
.controls
|
||||
.iter()
|
||||
.chain(&self.cards)
|
||||
.any(|r| contains(*r, p))
|
||||
|| self.edges.iter().any(|(a, b)| {
|
||||
let d = *b - *a;
|
||||
let length = d.x * d.x + d.y * d.y;
|
||||
let t = if length == 0. {
|
||||
0.
|
||||
} else {
|
||||
(((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length).clamp(0., 1.)
|
||||
};
|
||||
p.distance(*a + d * t) <= self.radius
|
||||
})
|
||||
{
|
||||
return Hit::Occluded;
|
||||
}
|
||||
if arrange {
|
||||
return Hit::Destination(plan::Destination::LayoutOnly, None);
|
||||
}
|
||||
let hits: Vec<_> = self
|
||||
.targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| contains(t.region, p))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
for (i, a) in hits.iter().enumerate() {
|
||||
for b in &hits[i + 1..] {
|
||||
let a = &self.targets[*a];
|
||||
let b = &self.targets[*b];
|
||||
if !a.ancestors.contains(&b.element) && !b.ancestors.contains(&a.element) {
|
||||
return Hit::Ambiguous(hits);
|
||||
}
|
||||
}
|
||||
}
|
||||
match hits
|
||||
.into_iter()
|
||||
.max_by_key(|i| self.targets[*i].ancestors.len())
|
||||
{
|
||||
Some(i) => Hit::Destination(
|
||||
plan::Destination::Performer(self.targets[i].element),
|
||||
Some(i),
|
||||
),
|
||||
None => Hit::Destination(plan::Destination::OutsidePerformers, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Press {
|
||||
pub stamp: Stamp,
|
||||
pub token: Id,
|
||||
pub start: Point,
|
||||
pub scope: plan::Scope,
|
||||
pub frozen: Frozen,
|
||||
pub arrange: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Press(Arc<Press>),
|
||||
Preview(Stamp, Id, Option<Point>),
|
||||
Release(Stamp, Id, Option<Point>),
|
||||
Refused(u64, String),
|
||||
Cancel(Id),
|
||||
ArrangeNext,
|
||||
Unfocused,
|
||||
KeyboardBoundary,
|
||||
}
|
||||
pub struct Preview {
|
||||
pub token: Id,
|
||||
pub semantic: Arc<plan::SemanticPlan>,
|
||||
pub placement: plan::PlacementPlan,
|
||||
pub routed: Arc<RoutedView>,
|
||||
}
|
||||
struct Active {
|
||||
press: Arc<Press>,
|
||||
ids: BTreeMap<Id, Id>,
|
||||
cache: Vec<(
|
||||
plan::Destination,
|
||||
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
|
||||
)>,
|
||||
}
|
||||
pub struct Feedback {
|
||||
pub press: Arc<Press>,
|
||||
pub targets: Vec<usize>,
|
||||
pub text: String,
|
||||
pub refused: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub epoch: u64,
|
||||
pub arrange_next: bool,
|
||||
active: Option<Active>,
|
||||
pub preview: Option<Arc<Preview>>,
|
||||
pub feedback: Option<Feedback>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Capability<'a> {
|
||||
pub state: &'a State,
|
||||
pub clean: bool,
|
||||
pub snapshot: usize,
|
||||
}
|
||||
impl State {
|
||||
fn retire(&mut self) {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
self.active = None;
|
||||
self.preview = None;
|
||||
self.feedback = None;
|
||||
self.arrange_next = false;
|
||||
}
|
||||
}
|
||||
pub fn refusal(r: &plan::Refusal) -> String {
|
||||
match r {
|
||||
plan::Refusal::Ineligible(s) | plan::Refusal::InvalidCandidate(s) | plan::Refusal::LayoutRequired(s) => s.clone(),
|
||||
plan::Refusal::StaleScope => "The view changed. Start this drag again.".into(),
|
||||
plan::Refusal::MixedLayoutRequired => "This work has a distributed or mixed assignment. Drag a specific leaf; arranging this group needs a layout step.".into(),
|
||||
plan::Refusal::RoleExitRequired { message, .. } => message.clone(),
|
||||
plan::Refusal::MissingAllocationId(_) => "Assignment identity expired. Start this drag again.".into(),
|
||||
plan::Refusal::CollateralChangeRequired(records) => format!(
|
||||
"Assignment needs dependent edits to {}. Resolve these before dragging again.",
|
||||
records.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
impl App {
|
||||
pub fn allocation_drag_clean(&self) -> bool {
|
||||
self.allocation_input_owner()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
pub(crate) fn allocation_input_owner(&self) -> bool {
|
||||
self.page == Page::Diagram
|
||||
&& self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
|
||||
&& self.architecture.is_none()
|
||||
&& self.connection.is_none()
|
||||
&& self.model_menu.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& !self.busy
|
||||
&& !self.hosted.visible
|
||||
&& self.workflow.is_none()
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
}
|
||||
pub fn allocation_capability(&self) -> Capability<'_> {
|
||||
Capability {
|
||||
state: &self.allocation_drag,
|
||||
clean: self.allocation_drag_clean(),
|
||||
snapshot: Arc::as_ptr(&self.document.snapshot()) as usize,
|
||||
}
|
||||
}
|
||||
fn allocation_stamp_valid(&self, stamp: Stamp) -> bool {
|
||||
self.allocation_drag_clean()
|
||||
&& self.route_context() == Some(stamp.context)
|
||||
&& self.allocation_drag.epoch == stamp.epoch
|
||||
&& Arc::as_ptr(&self.document.snapshot()) as usize == stamp.snapshot
|
||||
&& self.pan == stamp.pan
|
||||
&& self.zoom == stamp.zoom
|
||||
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
|
||||
&& stamp.bounds.width > 0.
|
||||
&& stamp.bounds.height > 0.
|
||||
}
|
||||
/// Called before general input dispatch. Even equal-valued actions (Fit,
|
||||
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
|
||||
pub(super) fn allocation_before(&mut self, message: &Message) {
|
||||
if !matches!(
|
||||
message,
|
||||
Message::WorkDrag(_)
|
||||
| Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::SourceViewport
|
||||
) {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
}
|
||||
pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.route_context(),
|
||||
self.pan,
|
||||
self.zoom,
|
||||
self.canvas_size,
|
||||
self.window_size,
|
||||
self.allocation_drag_clean(),
|
||||
Arc::as_ptr(&self.document.snapshot()) as usize,
|
||||
)
|
||||
}
|
||||
pub(super) fn allocation_retire(&mut self) {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
fn select_work_clean(&mut self, press: &Press) -> bool {
|
||||
if !self.allocation_stamp_valid(press.stamp) {
|
||||
return false;
|
||||
}
|
||||
self.selected = press.scope.work;
|
||||
self.selected_node = Some(press.scope.placement);
|
||||
self.selected_bend = None;
|
||||
self.refresh_draft();
|
||||
true
|
||||
}
|
||||
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::ArrangeNext => {
|
||||
self.allocation_drag.retire();
|
||||
if self.allocation_drag_clean() {
|
||||
self.allocation_drag.arrange_next = true;
|
||||
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
|
||||
} else {
|
||||
self.status = FINISH_EDIT.into();
|
||||
}
|
||||
}
|
||||
Event::Unfocused => self.allocation_drag.retire(),
|
||||
Event::KeyboardBoundary => {
|
||||
if self.allocation_drag.active.is_some() {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
}
|
||||
Event::Cancel(token) => {
|
||||
if self
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.press.token == token)
|
||||
{
|
||||
self.allocation_drag.retire();
|
||||
self.status = "Work drag canceled".into();
|
||||
}
|
||||
}
|
||||
Event::Refused(epoch, text) => {
|
||||
if epoch == self.allocation_drag.epoch {
|
||||
self.allocation_drag.retire();
|
||||
self.status = text;
|
||||
}
|
||||
}
|
||||
Event::Press(press) => {
|
||||
if self.allocation_drag.active.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
if !self.allocation_stamp_valid(press.stamp)
|
||||
|| press.arrange != self.allocation_drag.arrange_next
|
||||
{
|
||||
self.status = if self.allocation_drag_clean() {
|
||||
"The view changed. Start this drag again.".into()
|
||||
} else {
|
||||
FINISH_EDIT.into()
|
||||
};
|
||||
return Task::none();
|
||||
}
|
||||
let checked = plan::capture(
|
||||
self.project(),
|
||||
press.scope.diagram.id,
|
||||
press.scope.work,
|
||||
press.scope.placement,
|
||||
);
|
||||
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
|
||||
self.status = checked
|
||||
.err()
|
||||
.as_ref()
|
||||
.map(refusal)
|
||||
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
|
||||
self.allocation_drag.retire();
|
||||
return Task::none();
|
||||
}
|
||||
let ids = press
|
||||
.scope
|
||||
.leaves
|
||||
.keys()
|
||||
.map(|id| (*id, Id::new_v4()))
|
||||
.collect();
|
||||
self.allocation_drag.arrange_next = false;
|
||||
self.allocation_drag.preview = None;
|
||||
self.allocation_drag.feedback = None;
|
||||
self.allocation_drag.active = Some(Active {
|
||||
press,
|
||||
ids,
|
||||
cache: Vec::new(),
|
||||
});
|
||||
}
|
||||
Event::Preview(stamp, token, pointer) => {
|
||||
self.allocation_motion(stamp, token, pointer, false)
|
||||
}
|
||||
Event::Release(stamp, token, pointer) => {
|
||||
self.allocation_motion(stamp, token, pointer, true)
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn allocation_motion(
|
||||
&mut self,
|
||||
stamp: Stamp,
|
||||
token: Id,
|
||||
pointer: Option<Point>,
|
||||
release: bool,
|
||||
) {
|
||||
if self
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_none_or(|a| a.press.token != token)
|
||||
{
|
||||
return; // An old packet cannot cancel a newer gesture.
|
||||
}
|
||||
if !self.allocation_stamp_valid(stamp) {
|
||||
self.allocation_drag.retire();
|
||||
self.status = if self.allocation_drag_clean() {
|
||||
"The view changed. Start this drag again.".into()
|
||||
} else {
|
||||
FINISH_EDIT.into()
|
||||
};
|
||||
return;
|
||||
}
|
||||
let Some(mut active) = self.allocation_drag.active.take() else {
|
||||
return;
|
||||
};
|
||||
if active.press.token != token || active.press.stamp != stamp {
|
||||
self.allocation_drag.active = Some(active);
|
||||
return;
|
||||
}
|
||||
let press = active.press.clone();
|
||||
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
|
||||
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
|
||||
let mut targets = Vec::new();
|
||||
let mut preview = None;
|
||||
let result: Result<String, String> = match hit {
|
||||
Hit::OutsideViewport => Err("Work drag canceled outside the canvas.".into()),
|
||||
_ if !moved => Ok("Work selected".into()),
|
||||
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
|
||||
Hit::Ambiguous(indices) => {
|
||||
targets = indices;
|
||||
Err("Two unrelated performers overlap here. Use an exposed region.".into())
|
||||
}
|
||||
Hit::Destination(destination, target) => {
|
||||
targets.extend(target);
|
||||
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
|
||||
result.clone()
|
||||
} else {
|
||||
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
|
||||
active.cache.push((destination, result.clone()));
|
||||
result
|
||||
};
|
||||
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
|
||||
if stamp.zoom * 16. < 9. {
|
||||
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
|
||||
}
|
||||
let pointer = pointer.unwrap();
|
||||
let delta = pointer - press.start;
|
||||
let original = press.scope.placements[&press.scope.placement].position;
|
||||
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
|
||||
let presentation = plan::PresentationContext {
|
||||
viewport: stamp.viewport(), zoom: stamp.zoom,
|
||||
target: target.map(|i| {
|
||||
let t = &press.frozen.targets[i];
|
||||
plan::TargetPresentation { placement: t.placement, label: t.label }
|
||||
}),
|
||||
};
|
||||
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
|
||||
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
|
||||
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
|
||||
};
|
||||
if release {
|
||||
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
|
||||
if let Some(command) = command {
|
||||
// Bypass applying selectors and unrelated repair helpers. The
|
||||
// planner's exact Batch enters the authoritative source pipeline.
|
||||
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
|
||||
self.sync();
|
||||
self.selected = press.scope.work;
|
||||
self.selected_node = Some(press.scope.placement);
|
||||
self.refresh_draft();
|
||||
}
|
||||
} else {
|
||||
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
|
||||
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
|
||||
}
|
||||
Ok(label)
|
||||
})
|
||||
}
|
||||
};
|
||||
let refused = result.is_err();
|
||||
let text = result.unwrap_or_else(|text| text);
|
||||
self.status = text.clone();
|
||||
if release {
|
||||
self.allocation_drag.retire();
|
||||
} else {
|
||||
self.allocation_drag.active = Some(active);
|
||||
self.allocation_drag.preview = preview;
|
||||
self.allocation_drag.feedback = Some(Feedback {
|
||||
press,
|
||||
targets,
|
||||
text,
|
||||
refused,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "allocation_drag_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Actual App root, retained Iced tree, property widgets and production renderer.
|
||||
//! Unlike packet-only tests, these prove the next key still reaches the field.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard};
|
||||
|
||||
fn character(text: &str, modifiers: keyboard::Modifiers) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(text.into()),
|
||||
modified_key: keyboard::Key::Character(text.into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: Some(text.into()),
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
async fn mount() -> (Fixture, Mounted) {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
f.app.window_size = iced::Size::new(2000., 1400.);
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
// First sensor measurement intentionally Fits; now use a fixed known camera.
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
(f, mounted)
|
||||
}
|
||||
fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID)
|
||||
}
|
||||
fn at(mounted: &mut Mounted, f: &Fixture, world: Position) -> Point {
|
||||
let bounds = canvas_bounds(mounted, &f.app);
|
||||
Point::new(
|
||||
bounds.x + world.x * f.app.zoom + f.app.pan.x,
|
||||
bounds.y + world.y * f.app.zoom + f.app.pan.y,
|
||||
)
|
||||
}
|
||||
fn name_field(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|b| b.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("actual inspector Name TextInput")
|
||||
}
|
||||
fn description_field(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.map(|(_, bounds, _)| bounds)
|
||||
.filter(|b| b.x > app.window_size.width - 320. && b.height >= 90.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("actual inspector Description TextEditor")
|
||||
}
|
||||
fn is_focused(mounted: &mut Mounted, app: &App, field: Rectangle) -> bool {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, focused)| *bounds == field && *focused)
|
||||
}
|
||||
async fn type_key(mounted: &mut Mounted, app: &mut App, text: &str) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[character(text, keyboard::Modifiers::empty())],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field: Rectangle) {
|
||||
let before = f.app.document.bundle().clone();
|
||||
let selected = (f.app.selected, f.app.selected_node);
|
||||
let point = at(mounted, f, xy(140., 535.));
|
||||
let messages = mounted.click(&mut f.app, point).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Refused(..))))
|
||||
);
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selected);
|
||||
assert!(
|
||||
is_focused(mounted, &f.app, field),
|
||||
"refused press must not reach the outside-press unfocus handler"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_dirty_name_retains_focus_and_middle_caret_for_next_key() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let field = name_field(&mut mounted, &f.app);
|
||||
mounted.click(&mut f.app, field.center()).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Home)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
type_key(&mut mounted, &mut f.app, "X").await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::ArrowLeft)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XWork");
|
||||
refused_click_keeps_field(&mut mounted, &mut f, field).await;
|
||||
let messages = type_key(&mut mounted, &mut f.app, "Y").await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::Field(path, _) if path == "/name"))
|
||||
);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXWork");
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_dirty_description_retains_focus_and_middle_caret_for_next_key() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let field = description_field(&mut mounted, &f.app);
|
||||
mounted
|
||||
.click(&mut f.app, Point::new(field.x + 16., field.y + 16.))
|
||||
.await;
|
||||
type_key(&mut mounted, &mut f.app, "A").await;
|
||||
type_key(&mut mounted, &mut f.app, "B").await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::ArrowLeft)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
|
||||
"AB"
|
||||
);
|
||||
refused_click_keeps_field(&mut mounted, &mut f, field).await;
|
||||
let messages = type_key(&mut mounted, &mut f.app, "X").await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::FieldText(path, _) if path == "/description"))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
|
||||
"AXB"
|
||||
);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty_owner_refuses() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let shortcut = character("a", keyboard::Modifiers::ALT);
|
||||
let messages = mounted
|
||||
.batch(&mut f.app, &[shortcut.clone()], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::ArrangeNext)))
|
||||
);
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
mounted
|
||||
.batch(&mut f.app, &[shortcut.clone()], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let from = at(&mut mounted, &f, xy(140., 535.));
|
||||
let to = from + Vector::new(30., 0.);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange))
|
||||
);
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
assert!(f.app.project().relations.is_empty());
|
||||
let field = name_field(&mut mounted, &f.app);
|
||||
mounted.click(&mut f.app, field.center()).await;
|
||||
type_key(&mut mounted, &mut f.app, "X").await;
|
||||
let raw = f.app.draft.as_ref().unwrap().inputs["/name"].raw.clone();
|
||||
let before = f.app.document.bundle().clone();
|
||||
mounted
|
||||
.batch(&mut f.app, &[shortcut], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(f.app.status, FINISH_EDIT);
|
||||
assert!(is_focused(&mut mounted, &f.app, field));
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, raw);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
fn canvas_pixels(pixels: &[u8], width: usize, canvas: Rectangle) -> Vec<u8> {
|
||||
let x = canvas.x.ceil() as usize;
|
||||
let y = canvas.y.ceil() as usize;
|
||||
let right = (canvas.x + canvas.width).floor() as usize;
|
||||
let bottom = (canvas.y + canvas.height).floor() as usize;
|
||||
(y..bottom)
|
||||
.flat_map(|row| {
|
||||
pixels[(row * width + x) * 4..(row * width + right) * 4]
|
||||
.iter()
|
||||
.copied()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_changes() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let mut project = f.app.project().clone();
|
||||
let target = project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.a)
|
||||
.unwrap();
|
||||
target.position.y = -41.;
|
||||
target.size.height = 300.;
|
||||
replace(&mut f, project);
|
||||
f.app.zoom = 0.75;
|
||||
f.app.pan = Vector::new(15., 15.);
|
||||
let from = at(&mut mounted, &f, xy(140., 535.));
|
||||
let to = at(&mut mounted, &f, xy(200., 80.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
let feedback = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.expect("active frozen destination");
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
||||
assert!(target.label.position.y * f.app.zoom + f.app.pan.y < 14.);
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let first = mounted.pixels(&f.app);
|
||||
f.app.allocation_drag.feedback.as_mut().unwrap().text =
|
||||
"Very different drag status, retained outside the readable canvas. ".repeat(10);
|
||||
let second = mounted.pixels(&f.app);
|
||||
assert_ne!(first, second, "the actual toolbar status changes");
|
||||
assert_eq!(
|
||||
canvas_pixels(&first, f.app.window_size.width as usize, canvas),
|
||||
canvas_pixels(&second, f.app.window_size.width as usize, canvas),
|
||||
"status must not obscure any candidate header or frozen target label"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
use super::*;
|
||||
use crate::diagram::{DiagramCanvas, State as CanvasState};
|
||||
use iced::{
|
||||
mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
|
||||
#[path = "allocation_drag_full_tree_tests.rs"]
|
||||
mod full_tree;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
a: Id,
|
||||
b: Id,
|
||||
work: Id,
|
||||
node: Id,
|
||||
diagram: Id,
|
||||
}
|
||||
fn xy(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(20., 30.), iced::Size::new(2200., 1400.))
|
||||
}
|
||||
fn screen(p: Position) -> Point {
|
||||
Point::new(bounds().x + p.x, bounds().y + p.y)
|
||||
}
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let (performer, other, work) = if layer == Layer::Oa {
|
||||
(Kind::Entity, Kind::Actor, Kind::Activity)
|
||||
} else {
|
||||
(Kind::System, Kind::SystemActor, Kind::Function)
|
||||
};
|
||||
let a = p.add_element(performer, layer, "A", None).unwrap();
|
||||
let b = p.add_element(other, layer, "B", None).unwrap();
|
||||
let work = p.add_element(work, layer, "Work", None).unwrap();
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
p.place(diagram, a, xy(100., 100.));
|
||||
p.place(diagram, b, xy(900., 100.));
|
||||
let node = p.place(diagram, work, xy(100., 500.));
|
||||
let mut app = App::with_project(p);
|
||||
app.active_diagram = Some(diagram);
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
app.fit_to_view = false;
|
||||
Fixture {
|
||||
app,
|
||||
a,
|
||||
b,
|
||||
work,
|
||||
node,
|
||||
diagram,
|
||||
}
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
placing: false,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
fn pointer(
|
||||
canvas: &DiagramCanvas<'_>,
|
||||
state: &mut CanvasState,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> Option<Message> {
|
||||
canvas
|
||||
.update(
|
||||
state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn start(f: &Fixture) -> Point {
|
||||
let p = f.app.project().diagrams[&f.diagram].nodes[&f.node].position;
|
||||
screen(xy(p.x + 40., p.y + 35.))
|
||||
}
|
||||
fn batch(app: &App, from: Point, to: Point) -> Vec<Message> {
|
||||
let canvas = canvas(app);
|
||||
let mut state = CanvasState::default();
|
||||
[
|
||||
mouse::Event::CursorMoved { position: from },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: to },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| pointer(&canvas, &mut state, e, to))
|
||||
.collect()
|
||||
}
|
||||
fn send(app: &mut App, events: Vec<Message>) {
|
||||
for e in events {
|
||||
let _ = app.update(e);
|
||||
}
|
||||
}
|
||||
fn press(f: &Fixture) -> Arc<Press> {
|
||||
batch(&f.app, start(f), start(f))
|
||||
.into_iter()
|
||||
.find_map(|m| match m {
|
||||
Message::WorkDrag(Event::Press(p)) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.expect("actual clean work-body press")
|
||||
}
|
||||
fn drag(f: &mut Fixture, to: Point) {
|
||||
let events = batch(&f.app, start(f), to);
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(Message::WorkDrag(Event::Press(_)))
|
||||
));
|
||||
assert!(events.iter().all(|m| matches!(m, Message::WorkDrag(_))));
|
||||
send(&mut f.app, events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_oa_sa_ordered_incoming_between_outgoing_are_exact_single_history_steps() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let original = f.app.project().clone();
|
||||
let original_source = f.app.document.bundle().clone();
|
||||
drag(&mut f, screen(xy(160., 140.)));
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
let relation = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Allocation)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!((relation.source(), relation.target()), (f.a, f.work));
|
||||
assert_eq!(f.app.project().elements, original.elements);
|
||||
let after_in = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &original_source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.document.bundle(), &after_in);
|
||||
drag(&mut f, screen(xy(960., 140.)));
|
||||
let reassigned = &f.app.project().relations[&relation.id];
|
||||
assert_eq!(reassigned.source(), f.b, "{}", f.app.status);
|
||||
assert_eq!(reassigned.name, relation.name);
|
||||
assert_eq!(reassigned.description, relation.description);
|
||||
let before_out = f.app.document.bundle().clone();
|
||||
drag(&mut f, screen(xy(600., 700.)));
|
||||
assert!(
|
||||
!f.app.project().relations.contains_key(&relation.id),
|
||||
"{}",
|
||||
f.app.status
|
||||
);
|
||||
assert_eq!(f.app.project().elements, original.elements);
|
||||
let after_out = f.app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&after_out, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*f.app.project()
|
||||
);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &before_out);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.document.bundle(), &after_out);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_clean_stationary_selects_exact_work_without_revision_or_legacy_apply() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let before = f.app.document.snapshot();
|
||||
let events = batch(&f.app, start(&f), start(&f));
|
||||
send(&mut f.app, events);
|
||||
assert_eq!(f.app.selected, f.work);
|
||||
assert_eq!(f.app.selected_node, Some(f.node));
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
|
||||
for same_selection in [false, true] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
if same_selection {
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
}
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Keep my unfinished name".into(),
|
||||
));
|
||||
let selection = (f.app.selected, f.app.selected_node);
|
||||
let before = f.app.document.snapshot();
|
||||
let dirty = f.app.draft.as_ref().unwrap().value.clone();
|
||||
let input = &f.app.draft.as_ref().unwrap().inputs["/name"];
|
||||
let raw = input.raw.clone();
|
||||
let cursor = input.editor.cursor();
|
||||
let events = batch(&f.app, start(&f), screen(xy(160., 140.)));
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(Message::WorkDrag(Event::Refused(..)))
|
||||
));
|
||||
assert!(!events.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
)));
|
||||
send(&mut f.app, events);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selection);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().value, dirty);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, raw);
|
||||
assert_eq!(
|
||||
f.app.draft.as_ref().unwrap().inputs["/name"]
|
||||
.editor
|
||||
.cursor(),
|
||||
cursor
|
||||
);
|
||||
assert!(f.app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.status, FINISH_EDIT);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_and_modal_admission_reject_prebuilt_clean_press_without_selecting() {
|
||||
for mode in 0..4 {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let p = press(&f);
|
||||
let selection = f.app.selected;
|
||||
let source = f.app.document.bundle().clone();
|
||||
match mode {
|
||||
0 => {
|
||||
let _ = f.app.update(Message::SourceEdit(
|
||||
iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Insert('x'),
|
||||
),
|
||||
));
|
||||
}
|
||||
1 => f.app.busy = true,
|
||||
2 => f.app.delete_pending = true,
|
||||
_ => f.app.hosted.visible = true,
|
||||
}
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
)));
|
||||
assert_eq!(f.app.selected, selection);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_roundtrip_equal_fit_undo_resize_and_focus_retire_press_before_replay() {
|
||||
for interruption in 0..6 {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let p = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
let before = f.app.document.bundle().clone();
|
||||
match interruption {
|
||||
0 => {
|
||||
f.app.delete_pending = true;
|
||||
let _ = f.app.update(Message::CancelDelete);
|
||||
}
|
||||
1 => {
|
||||
let _ = f.app.update(Message::Fit);
|
||||
f.app.zoom = p.stamp.zoom;
|
||||
f.app.pan = p.stamp.pan;
|
||||
}
|
||||
2 => {
|
||||
let _ = f.app.update(Message::Undo);
|
||||
}
|
||||
3 => {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::CanvasResized(iced::Size::new(2300., 1400.)));
|
||||
f.app.canvas_size = None;
|
||||
}
|
||||
4 => {
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Unfocused));
|
||||
}
|
||||
_ => {
|
||||
let _ = f.app.update(Message::Escape);
|
||||
}
|
||||
}
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_packets_do_not_cancel_a_newer_token_and_each_release_is_consumed_once() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let old = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
let _ = f.app.update(Message::Escape);
|
||||
let new = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(new.clone())));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
old.stamp,
|
||||
old.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
)));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
new.token
|
||||
);
|
||||
let release = Message::WorkDrag(Event::Release(
|
||||
new.stamp,
|
||||
new.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
));
|
||||
let _ = f.app.update(release.clone());
|
||||
let revision = f.app.document.revision();
|
||||
let _ = f.app.update(release);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(revision, 1, "{}", f.app.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let _ = f.app.update(Message::Command(Command::Rename(
|
||||
f.work,
|
||||
"Temporary".into(),
|
||||
)));
|
||||
let _ = f.app.update(Message::Undo);
|
||||
let before = f.app.document.bundle().clone();
|
||||
let revision = f.app.document.revision();
|
||||
let _ = f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
let p = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
assert!(p.arrange);
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(p.start + Vector::new(4., 0.)),
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(f.app.document.can_redo());
|
||||
assert!(f.app.project().relations.is_empty());
|
||||
assert!(!press(&f).arrange);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_release_is_cancel_and_does_not_remove_an_assignment() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
drag(&mut f, screen(xy(160., 140.)));
|
||||
let before = f.app.document.bundle().clone();
|
||||
let revision = f.app.document.revision();
|
||||
drag(
|
||||
&mut f,
|
||||
Point::new(bounds().x + bounds().width, bounds().y + 300.),
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let p = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
let a = screen(xy(160., 140.));
|
||||
let b = screen(xy(960., 140.));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
|
||||
let first = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.expect(&f.app.status)
|
||||
.semantic
|
||||
.clone();
|
||||
let stable = first
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(a + Vector::new(1., 0.)),
|
||||
)));
|
||||
assert!(Arc::ptr_eq(
|
||||
&first,
|
||||
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
|
||||
));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(b))));
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert!(!Arc::ptr_eq(&first, &preview.semantic));
|
||||
assert_eq!(preview.semantic.project().relations[&stable].source(), f.b);
|
||||
assert_eq!(
|
||||
preview.placement.view,
|
||||
projection::project_diagram_with_index(
|
||||
preview.semantic.project(),
|
||||
&preview.placement.diagram,
|
||||
preview.semantic.index()
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
preview.placement.view,
|
||||
projection::project_diagram_with_index(
|
||||
f.app.project(),
|
||||
&preview.placement.diagram,
|
||||
f.app.document.view_index()
|
||||
)
|
||||
);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
|
||||
assert!(Arc::ptr_eq(
|
||||
&first,
|
||||
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
|
||||
));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().cache.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_targets_use_half_open_semantic_priority_and_refuse_incomparable_overlap() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let p = press(&f);
|
||||
let mut frozen = p.frozen.clone();
|
||||
let ai = frozen
|
||||
.targets
|
||||
.iter()
|
||||
.position(|t| t.element == f.a)
|
||||
.unwrap();
|
||||
let bi = frozen
|
||||
.targets
|
||||
.iter()
|
||||
.position(|t| t.element == f.b)
|
||||
.unwrap();
|
||||
frozen.targets[ai].region = Rectangle::new(Point::new(100., 100.), iced::Size::new(200., 200.));
|
||||
frozen.targets[bi].region = Rectangle::new(Point::new(300., 100.), iced::Size::new(200., 200.));
|
||||
assert!(
|
||||
matches!(frozen.resolve(p.stamp, Some(screen(xy(300., 110.))), false),
|
||||
Hit::Destination(plan::Destination::Performer(id), _) if id == f.b)
|
||||
);
|
||||
assert!(matches!(
|
||||
frozen.resolve(p.stamp, Some(screen(xy(500., 110.))), false),
|
||||
Hit::Destination(plan::Destination::OutsidePerformers, None)
|
||||
));
|
||||
frozen.targets[bi].region = Rectangle::new(Point::new(150., 150.), iced::Size::new(50., 50.));
|
||||
assert!(matches!(
|
||||
frozen.resolve(p.stamp, Some(screen(xy(160., 160.))), false),
|
||||
Hit::Ambiguous(_)
|
||||
));
|
||||
frozen.targets[bi].ancestors.push(f.a);
|
||||
assert!(
|
||||
matches!(frozen.resolve(p.stamp, Some(screen(xy(160., 160.))), false),
|
||||
Hit::Destination(plan::Destination::Performer(id), _) if id == f.b)
|
||||
);
|
||||
frozen.cards.push(Rectangle::new(
|
||||
Point::new(160., 160.),
|
||||
iced::Size::new(20., 20.),
|
||||
));
|
||||
assert_eq!(
|
||||
frozen.resolve(p.stamp, Some(screen(xy(160., 160.))), false),
|
||||
Hit::Occluded
|
||||
);
|
||||
assert_eq!(frozen.resolve(p.stamp, None, false), Hit::OutsideViewport);
|
||||
assert_eq!(
|
||||
frozen.resolve(
|
||||
p.stamp,
|
||||
Some(Point::new(bounds().x + 50., bounds().y + 15.)),
|
||||
false
|
||||
),
|
||||
Hit::Destination(plan::Destination::OutsidePerformers, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_uses_ordered_native_position_before_later_motion_in_same_batch() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let from = start(&f);
|
||||
let a = screen(xy(160., 140.));
|
||||
let b = screen(xy(960., 140.));
|
||||
let mut state = CanvasState::default();
|
||||
let canvas = canvas(&f.app);
|
||||
let events: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: from },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: a },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| pointer(&canvas, &mut state, e, b))
|
||||
.collect();
|
||||
send(&mut f.app, events);
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
Some(f.a),
|
||||
"{}",
|
||||
f.app.status
|
||||
);
|
||||
}
|
||||
|
||||
fn replace(f: &mut Fixture, project: Project) {
|
||||
f.app.document = crate::document::Document::from_project(project, Default::default()).unwrap();
|
||||
f.app.sync();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_snapped_position_on_opposite_shared_border_rebuilds_semantics_even_if_layout_refuses() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut project = f.app.project().clone();
|
||||
let diagram = project.diagrams.get_mut(&f.diagram).unwrap();
|
||||
diagram
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.b)
|
||||
.unwrap()
|
||||
.position
|
||||
.x = 324.;
|
||||
replace(&mut f, project);
|
||||
let p = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
let left = screen(xy(323.5, 140.));
|
||||
let right = screen(xy(324.5, 140.));
|
||||
assert_eq!(
|
||||
((left.x - p.start.x + 100.) / 10.).round(),
|
||||
((right.x - p.start.x + 100.) / 10.).round()
|
||||
);
|
||||
for at in [left, right] {
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(at),
|
||||
)));
|
||||
}
|
||||
let active = f.app.allocation_drag.active.as_ref().unwrap();
|
||||
assert_eq!(active.cache.len(), 2);
|
||||
let a = active
|
||||
.cache
|
||||
.iter()
|
||||
.find(|(d, _)| *d == plan::Destination::Performer(f.a))
|
||||
.unwrap()
|
||||
.1
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let b = active
|
||||
.cache
|
||||
.iter()
|
||||
.find(|(d, _)| *d == plan::Destination::Performer(f.b))
|
||||
.unwrap()
|
||||
.1
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(a, b));
|
||||
let stable = a
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
assert_eq!(b.project().relations[&stable].source(), f.b);
|
||||
assert!(f.app.project().relations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_refusal_is_atomic_and_a_new_gesture_can_retry_against_live_revision() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let before = f.app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = before.files.values().map(String::len).sum();
|
||||
f.app.document = crate::document::Document::from_bundle(before.clone(), limits).unwrap();
|
||||
f.app.sync();
|
||||
let snapshot = f.app.document.snapshot();
|
||||
drag(&mut f, screen(xy(160., 140.)));
|
||||
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.status.contains("retry"), "{}", f.app.status);
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
f.app.document = crate::document::Document::from_bundle(before, Default::default()).unwrap();
|
||||
f.app.sync();
|
||||
drag(&mut f, screen(xy(160., 140.)));
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
Some(f.a),
|
||||
"{}",
|
||||
f.app.status
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_role_only_outgoing_refusal_retains_placement_and_same_performer_creates_no_allocation() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut project = f.app.project().clone();
|
||||
let role = project
|
||||
.add_element(Kind::Role, Layer::Oa, "Responder", None)
|
||||
.unwrap();
|
||||
project.relate(RelationKind::RoleAssignment, "Responsible", role, f.work);
|
||||
project.relate(RelationKind::RoleRealization, "Realizes", f.a, role);
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.node)
|
||||
.unwrap()
|
||||
.position = xy(150., 200.);
|
||||
replace(&mut f, project);
|
||||
let relations = f.app.project().relations.clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
drag(&mut f, screen(xy(650., 700.)));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(
|
||||
f.app.status.to_lowercase().contains("role"),
|
||||
"{}",
|
||||
f.app.status
|
||||
);
|
||||
let target = press(&f)
|
||||
.frozen
|
||||
.targets
|
||||
.iter()
|
||||
.find(|t| t.element == f.a)
|
||||
.unwrap()
|
||||
.region;
|
||||
drag(&mut f, screen(xy(target.x + 45., target.y + 40.)));
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
assert_eq!(f.app.project().relations, relations);
|
||||
assert!(
|
||||
!f.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_uniform_parent_moves_hidden_placements_and_assigns_only_leaves() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut project = f.app.project().clone();
|
||||
let child = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Child", Some(f.work))
|
||||
.unwrap();
|
||||
let hidden = project.place(f.diagram, child, xy(150., 600.));
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.node)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
replace(&mut f, project);
|
||||
let before = f.app.project().diagrams[&f.diagram].nodes.clone();
|
||||
drag(&mut f, screen(xy(960., 140.)));
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
let after = &f.app.project().diagrams[&f.diagram].nodes;
|
||||
let dx = after[&f.node].position.x - before[&f.node].position.x;
|
||||
let dy = after[&f.node].position.y - before[&f.node].position.y;
|
||||
assert_eq!(
|
||||
after[&hidden].position,
|
||||
xy(
|
||||
before[&hidden].position.x + dx,
|
||||
before[&hidden].position.y + dy
|
||||
)
|
||||
);
|
||||
assert!(after[&f.node].collapsed);
|
||||
let assigned: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Allocation)
|
||||
.collect();
|
||||
assert_eq!(assigned.len(), 1);
|
||||
assert_eq!((assigned[0].source(), assigned[0].target()), (f.b, child));
|
||||
assert_eq!(f.app.project().elements[&child].parent, Some(f.work));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_dirty_port_press_cannot_fall_through_to_applying_port_selection() {
|
||||
let mut f = fixture(Layer::Sa);
|
||||
let mut project = f.app.project().clone();
|
||||
let port = Id::new_v4();
|
||||
project.elements.insert(
|
||||
port,
|
||||
make_port(
|
||||
&project,
|
||||
port,
|
||||
f.work,
|
||||
"Send",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
replace(&mut f, project);
|
||||
let view = projection::project_view(f.app.project(), f.diagram);
|
||||
let at = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == port)
|
||||
.unwrap()
|
||||
.position,
|
||||
);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/name".into(), "Keep dirty name".into()));
|
||||
let selection = f.app.selected;
|
||||
let before = f.app.document.bundle().clone();
|
||||
let events = batch(&f.app, at, at + Vector::new(30., 0.));
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(Message::WorkDrag(Event::Refused(..)))
|
||||
));
|
||||
send(&mut f.app, events);
|
||||
assert_eq!(f.app.selected, selection);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(f.app.draft_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_collapsed_target_uses_only_visible_header_and_port_edge_controls_occlude() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut project = f.app.project().clone();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.a)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
replace(&mut f, project);
|
||||
let p = press(&f);
|
||||
let mut frozen = p.frozen.clone();
|
||||
let t = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
|
||||
let bottom = xy(t.region.x + 30., t.region.y + t.region.height);
|
||||
assert!(t.region.height <= 62.);
|
||||
assert_eq!(
|
||||
frozen.resolve(p.stamp, Some(screen(bottom)), false),
|
||||
Hit::Destination(plan::Destination::OutsidePerformers, None)
|
||||
);
|
||||
frozen.controls.push(Rectangle::new(
|
||||
Point::new(500., 500.),
|
||||
iced::Size::new(20., 20.),
|
||||
));
|
||||
assert_eq!(
|
||||
frozen.resolve(p.stamp, Some(screen(xy(500., 500.))), false),
|
||||
Hit::Occluded
|
||||
);
|
||||
frozen
|
||||
.edges
|
||||
.push((Point::new(600., 600.), Point::new(700., 600.)));
|
||||
assert_eq!(
|
||||
frozen.resolve(p.stamp, Some(screen(xy(650., 605.))), false),
|
||||
Hit::Occluded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_behavior_draft_refuses_actual_work_press_and_equal_document_replacement_retires_packet()
|
||||
{
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let p = press(&f);
|
||||
let project = f.app.project().clone();
|
||||
let _ = f.app.behavior.update(
|
||||
&project,
|
||||
crate::behavior::Event::Creation(crate::behavior::creation::Event::Open(Layer::Oa)),
|
||||
);
|
||||
assert!(f.app.behavior.has_pending());
|
||||
let events = batch(&f.app, start(&f), screen(xy(160., 140.)));
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(Message::WorkDrag(Event::Refused(..)))
|
||||
));
|
||||
let source = f.app.document.bundle().clone();
|
||||
send(&mut f.app, events);
|
||||
assert!(f.app.behavior.has_pending());
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let _ = f.app.behavior.update(
|
||||
&project,
|
||||
crate::behavior::Event::Creation(crate::behavior::creation::Event::Cancel),
|
||||
);
|
||||
replace(&mut f, project);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_canvas_origin_change_cancels_held_work_before_any_legacy_release() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let from = start(&f);
|
||||
let mut state = CanvasState::default();
|
||||
let events = {
|
||||
let canvas = canvas(&f.app);
|
||||
let mut events = Vec::new();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: from },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
] {
|
||||
if let Some(message) = pointer(&canvas, &mut state, event, from) {
|
||||
events.push(message);
|
||||
}
|
||||
}
|
||||
let moved_bounds = Rectangle {
|
||||
x: bounds().x + 1.,
|
||||
..bounds()
|
||||
};
|
||||
let canceled = canvas
|
||||
.update(
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
moved_bounds,
|
||||
mouse::Cursor::Available(screen(xy(160., 140.))),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
.unwrap();
|
||||
assert!(matches!(canceled, Message::WorkDrag(Event::Cancel(_))));
|
||||
events.push(canceled);
|
||||
events
|
||||
};
|
||||
let source = f.app.document.bundle().clone();
|
||||
send(&mut f.app, events);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_status_leaves_transformed_top_edge_work_header_readable() {
|
||||
for zoom in [0.75, 1.25] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
f.app.zoom = zoom;
|
||||
f.app.pan = Vector::new(20. * zoom, 20. * zoom);
|
||||
let screen = |world: Position| {
|
||||
Point::new(
|
||||
bounds().x + (world.x + 20.) * zoom,
|
||||
bounds().y + (world.y + 20.) * zoom,
|
||||
)
|
||||
};
|
||||
let from = screen(xy(140., 580.)); // 80-unit offset: release pointer clears the old band.
|
||||
let to = screen(xy(640., 60.));
|
||||
let mut events = batch(&f.app, from, to);
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
let header = preview
|
||||
.placement
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == f.node)
|
||||
.unwrap();
|
||||
assert_eq!(header.position.y, -20.);
|
||||
assert!((screen(header.position).y - bounds().y).abs() < 0.01);
|
||||
assert!(
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.targets
|
||||
.is_empty()
|
||||
);
|
||||
let _ = f.app.update(release);
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_status_leaves_transformed_top_edge_frozen_target_label_readable() {
|
||||
for zoom in [0.75, 1.25] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut project = f.app.project().clone();
|
||||
let target = project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.a)
|
||||
.unwrap();
|
||||
target.position.y = -41.;
|
||||
target.size.height = 300.;
|
||||
replace(&mut f, project);
|
||||
f.app.zoom = zoom;
|
||||
f.app.pan = Vector::new(20. * zoom, 20. * zoom);
|
||||
let screen = |world: Position| {
|
||||
Point::new(
|
||||
bounds().x + (world.x + 20.) * zoom,
|
||||
bounds().y + (world.y + 20.) * zoom,
|
||||
)
|
||||
};
|
||||
let mut events = batch(&f.app, screen(xy(140., 535.)), screen(xy(200., 80.)));
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
||||
let top = screen(target.label.position).y - bounds().y;
|
||||
assert!(
|
||||
top > 0. && top < 14.,
|
||||
"label starts in the formerly obscured band: {top}"
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_some());
|
||||
let _ = f.app.update(release);
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
Some(f.a)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
|
||||
|
||||
@@ -232,6 +232,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
editable: true,
|
||||
placing: true,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let action = canvas
|
||||
@@ -306,6 +307,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
|
||||
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
|
||||
|
||||
@@ -34,9 +34,13 @@ mod view_options_tests;
|
||||
#[path = "diagram/port_labels.rs"]
|
||||
mod port_labels;
|
||||
pub use port_labels::PortLabels;
|
||||
#[path = "diagram/work_drag.rs"]
|
||||
mod work_drag;
|
||||
pub use work_drag::WorkFeedback;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiagramCanvas<'a> {
|
||||
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
|
||||
pub project: &'a Project,
|
||||
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
|
||||
pub diagram: Id,
|
||||
@@ -51,6 +55,8 @@ pub struct DiagramCanvas<'a> {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
work: Option<Arc<crate::app::allocation_drag::Press>>,
|
||||
work_cache: RefCell<Option<Arc<Scene>>>,
|
||||
pointer: Option<Point>,
|
||||
keyboard_focus: bool,
|
||||
drag: Option<(Id, Point, Position)>,
|
||||
@@ -64,6 +70,7 @@ pub struct State {
|
||||
cache: RefCell<Option<CachedScene>>,
|
||||
}
|
||||
struct Scene {
|
||||
work: Option<Arc<crate::app::allocation_drag::Preview>>,
|
||||
view: ProjectedView,
|
||||
routed: Arc<RoutedView>,
|
||||
}
|
||||
@@ -450,6 +457,26 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
}
|
||||
fn scene(&self, state: &State) -> Arc<Scene> {
|
||||
if let Some(preview) = self.work_preview()
|
||||
&& state
|
||||
.work
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.token == preview.token)
|
||||
{
|
||||
if let Some(scene) = state.work_cache.borrow().as_ref()
|
||||
&& scene.work.as_ref().is_some_and(|p| Arc::ptr_eq(p, preview))
|
||||
{
|
||||
return scene.clone();
|
||||
}
|
||||
let scene = Arc::new(Scene {
|
||||
work: Some(preview.clone()),
|
||||
view: preview.placement.view.clone(),
|
||||
routed: preview.routed.clone(),
|
||||
});
|
||||
*state.work_cache.borrow_mut() = Some(scene.clone());
|
||||
return scene;
|
||||
}
|
||||
*state.work_cache.borrow_mut() = None;
|
||||
let preview = if state.context == Some(self.context) {
|
||||
state.preview
|
||||
} else {
|
||||
@@ -496,7 +523,11 @@ impl DiagramCanvas<'_> {
|
||||
route_diagram(self.project, &diagram, &view)
|
||||
},
|
||||
);
|
||||
let scene = Arc::new(Scene { view, routed });
|
||||
let scene = Arc::new(Scene {
|
||||
work: None,
|
||||
view,
|
||||
routed,
|
||||
});
|
||||
*state.cache.borrow_mut() = Some(CachedScene {
|
||||
context: self.context,
|
||||
preview,
|
||||
@@ -527,7 +558,11 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if let Some(action) = self.work_lifetime(state, event, bounds) {
|
||||
return Some(action);
|
||||
}
|
||||
if state.context != Some(self.context) {
|
||||
state.work = None;
|
||||
state.pointer = None;
|
||||
state.port_drag = None;
|
||||
state.connection_press = None;
|
||||
@@ -714,6 +749,22 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let scene = self.scene(state);
|
||||
let view = &scene.view;
|
||||
let world = self.world(p);
|
||||
if self.work_dirty_press(view, &scene.routed, p) {
|
||||
state.work = None;
|
||||
state.drag = None;
|
||||
state.preview = None;
|
||||
state.port_drag = None;
|
||||
state.route_drag = None;
|
||||
return Some(
|
||||
Action::publish(Message::WorkDrag(
|
||||
crate::app::allocation_drag::Event::Refused(
|
||||
self.allocation.unwrap().state.epoch,
|
||||
crate::app::allocation_drag::FINISH_EDIT.into(),
|
||||
),
|
||||
))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if self.placing && self.editable {
|
||||
return Some(
|
||||
Action::publish(Message::Architecture(
|
||||
@@ -795,6 +846,16 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(Action::publish(Message::Select(port.element)).and_capture());
|
||||
}
|
||||
if let Some(id) = self.hit(view, p) {
|
||||
if self.work_eligible(id) {
|
||||
return Some(self.begin_work(
|
||||
state,
|
||||
id,
|
||||
cursor.position()?,
|
||||
bounds,
|
||||
view,
|
||||
&scene.routed,
|
||||
));
|
||||
}
|
||||
let position = self
|
||||
.project
|
||||
.diagrams
|
||||
@@ -833,6 +894,18 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
if let Some(press) = &state.work {
|
||||
return Some(
|
||||
Action::publish(Message::WorkDrag(
|
||||
crate::app::allocation_drag::Event::Preview(
|
||||
press.stamp,
|
||||
press.token,
|
||||
cursor.position(),
|
||||
),
|
||||
))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let p = local?;
|
||||
if let Some((button, start)) = state.pan_start {
|
||||
state.pan_start = Some((button, p));
|
||||
@@ -865,6 +938,18 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
None
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if let Some(press) = state.work.take() {
|
||||
return Some(
|
||||
Action::publish(Message::WorkDrag(
|
||||
crate::app::allocation_drag::Event::Release(
|
||||
press.stamp,
|
||||
press.token,
|
||||
cursor.position(),
|
||||
),
|
||||
))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if let Some((button, start)) = state.pan_start {
|
||||
if button == mouse::Button::Middle {
|
||||
return Some(Action::capture());
|
||||
@@ -1011,6 +1096,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
f.translate(self.pan);
|
||||
f.scale(self.zoom);
|
||||
let scene = self.scene(state);
|
||||
let project = scene
|
||||
.work
|
||||
.as_ref()
|
||||
.map_or(self.project, |p| p.semantic.project());
|
||||
let view = &scene.view;
|
||||
let containers = view
|
||||
.nodes
|
||||
@@ -1019,10 +1108,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
.collect::<BTreeSet<_>>();
|
||||
for n in &view.nodes {
|
||||
if containers.contains(&n.id) {
|
||||
paint_node(&mut f, self.project, n, self.selected, true);
|
||||
paint_node(&mut f, project, n, self.selected, true);
|
||||
}
|
||||
}
|
||||
let highlighted = highlighted_exchanges(self.project, self.selected);
|
||||
let highlighted = highlighted_exchanges(project, self.selected);
|
||||
for edge in &view.edges {
|
||||
let Some(routed) = scene.routed.edges.get(&edge.id) else {
|
||||
continue;
|
||||
@@ -1045,7 +1134,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
} else {
|
||||
Color::from_rgb8(144, 158, 173)
|
||||
};
|
||||
let capability_label = relationship_stereotype(self.project, edge.id);
|
||||
let capability_label = relationship_stereotype(project, edge.id);
|
||||
let mut stroke =
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
@@ -1093,7 +1182,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some(relation) = self.project.relations.get(&edge.id) {
|
||||
if let Some(relation) = project.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, routed) {
|
||||
if end.navigable {
|
||||
let [a, tip, b] = navigation_mark(end.tip, end.inward);
|
||||
@@ -1127,13 +1216,13 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
paint_node(&mut f, self.project, n, self.selected, false);
|
||||
paint_node(&mut f, project, n, self.selected, false);
|
||||
} else {
|
||||
// Container headers stay readable when relationships traverse
|
||||
// the allocation enclosure. Children remain on top.
|
||||
let mut header = n.clone();
|
||||
header.size.height = 62.;
|
||||
paint_node(&mut f, self.project, &header, self.selected, true);
|
||||
paint_node(&mut f, project, &header, self.selected, true);
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
@@ -1141,17 +1230,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::kind_color(
|
||||
self.project.elements[&port.element]
|
||||
project.elements[&port.element]
|
||||
.parent
|
||||
.and_then(|id| self.project.elements.get(&id))
|
||||
.and_then(|id| project.elements.get(&id))
|
||||
.map(|e| e.kind())
|
||||
.unwrap_or(Kind::Port),
|
||||
)
|
||||
};
|
||||
let origin = Point::new(port.position.x - 7., port.position.y - 7.);
|
||||
let glyph = port_glyph(&self.project.elements[&port.element]);
|
||||
let glyph = port_glyph(&project.elements[&port.element]);
|
||||
let path = if glyph == PortGlyph::Function {
|
||||
let corners = port_triangle(&self.project.elements[&port.element], port);
|
||||
let corners = port_triangle(&project.elements[&port.element], port);
|
||||
Path::new(|p| {
|
||||
p.move_to(corners[0]);
|
||||
p.line_to(corners[1]);
|
||||
@@ -1181,7 +1270,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
if matches!(glyph, PortGlyph::Flow(_)) {
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
for [a, b] in flow_mark(&self.project.elements[&port.element], port) {
|
||||
for [a, b] in flow_mark(&project.elements[&port.element], port) {
|
||||
p.move_to(a);
|
||||
p.line_to(b);
|
||||
}
|
||||
@@ -1343,7 +1432,8 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
) -> mouse::Interaction {
|
||||
if (self.connection.is_some() || self.placing) && cursor.is_over(bounds) {
|
||||
mouse::Interaction::Crosshair
|
||||
} else if state.drag.is_some()
|
||||
} else if state.work.is_some()
|
||||
|| state.drag.is_some()
|
||||
|| state.pan_start.is_some()
|
||||
|| state.route_drag.is_some()
|
||||
|| state.port_drag.is_some()
|
||||
@@ -1900,6 +1990,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
zoom: 1.,
|
||||
@@ -1974,6 +2065,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
zoom: 0.6,
|
||||
@@ -2098,6 +2190,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: Some(&session),
|
||||
allocation: None,
|
||||
};
|
||||
let bounds = Rectangle::new(Point::new(260., 300.), Size::new(2400., 1600.));
|
||||
let at = |port: &oa_core::projection::ProjectedPort| {
|
||||
@@ -2171,6 +2264,7 @@ mod tests {
|
||||
armed.start(&p, &view, source.element);
|
||||
let armed_canvas = DiagramCanvas {
|
||||
connection: Some(&armed),
|
||||
allocation: None,
|
||||
..canvas
|
||||
};
|
||||
// Winit updates cursor coordinates through CursorMoved, not the
|
||||
@@ -2200,6 +2294,7 @@ mod tests {
|
||||
// A tool cancellation or navigation invalidates a queued release.
|
||||
let cancelled = DiagramCanvas {
|
||||
connection: None,
|
||||
allocation: None,
|
||||
context: Context {
|
||||
epoch: 1,
|
||||
..context
|
||||
@@ -2271,6 +2366,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
let pin = Point::new(400. * zoom + 150., -100. * zoom + 300.);
|
||||
@@ -2346,6 +2442,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
let pin = Point::new(350., 250.);
|
||||
@@ -2442,6 +2539,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
let first = canvas.scene(&state);
|
||||
@@ -2569,6 +2667,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
for end in ends {
|
||||
assert!(svg.contains(&format!(
|
||||
@@ -2622,6 +2721,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state);
|
||||
@@ -2695,6 +2795,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),point(cover)+canvas.pan),Some(Message::SelectNode(id)) if id==node.id)
|
||||
@@ -2727,6 +2828,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let span = &routed.edges[&edge].points;
|
||||
let pick = point(oa_core::routing::point_along_polyline(span, 0.4).unwrap());
|
||||
|
||||
@@ -64,6 +64,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
allocation: None,
|
||||
}
|
||||
}
|
||||
fn screen(app: &App, id: Id) -> Point {
|
||||
|
||||
@@ -72,6 +72,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
allocation: None,
|
||||
}
|
||||
}
|
||||
fn event(
|
||||
|
||||
@@ -18,6 +18,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ impl canvas::Program<Message> for PortLabels<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
if self.0.work_preview().is_some() {
|
||||
return vec![]; // The candidate canvas already paints its own ports.
|
||||
}
|
||||
if state.context == Some(self.0.context) && state.held_buttons != 0 {
|
||||
return vec![];
|
||||
}
|
||||
@@ -185,6 +188,7 @@ mod tests {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::*;
|
||||
use crate::app::allocation_drag::{self as input, Event, Press, Stamp};
|
||||
|
||||
impl DiagramCanvas<'_> {
|
||||
pub(crate) fn work_dirty_point(&self, bounds: Rectangle, screen: Point) -> bool {
|
||||
let diagram = &self.project.diagrams[&self.diagram];
|
||||
let view = self.projected(diagram);
|
||||
let routed = routing::route_diagram(self.project, diagram, &view);
|
||||
self.work_dirty_press(
|
||||
&view,
|
||||
&routed,
|
||||
Point::new(screen.x - bounds.x, screen.y - bounds.y),
|
||||
)
|
||||
}
|
||||
pub(super) fn work_preview(&self) -> Option<&Arc<input::Preview>> {
|
||||
let capability = self.allocation?;
|
||||
let feedback = capability.state.feedback.as_ref()?;
|
||||
(capability.clean
|
||||
&& feedback.press.stamp.context == self.context
|
||||
&& feedback.press.stamp.epoch == capability.state.epoch
|
||||
&& feedback.press.stamp.pan == self.pan
|
||||
&& feedback.press.stamp.zoom == self.zoom)
|
||||
.then_some(capability.state.preview.as_ref())
|
||||
.flatten()
|
||||
}
|
||||
pub(super) fn work_eligible(&self, placement: Id) -> bool {
|
||||
self.allocation.is_some()
|
||||
&& self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
|
||||
&& self.project.diagrams[&self.diagram]
|
||||
.nodes
|
||||
.get(&placement)
|
||||
.and_then(|n| self.project.elements.get(&n.element))
|
||||
.is_some_and(|e| e.kind().is_work())
|
||||
}
|
||||
pub(super) fn work_dirty_press(
|
||||
&self,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
local: Point,
|
||||
) -> bool {
|
||||
self.allocation.is_some_and(|c| !c.clean)
|
||||
&& !self.placing
|
||||
&& self.connection.is_none()
|
||||
&& self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
|
||||
&& (self.hit(view, local).is_some()
|
||||
|| self.hit_port(view, local).is_some()
|
||||
|| self.hit_edge(routed, local).is_some()
|
||||
|| self.hit_label(routed, local).is_some()
|
||||
|| self.hit_association_label(routed, local).is_some())
|
||||
}
|
||||
pub(super) fn begin_work(
|
||||
&self,
|
||||
state: &mut State,
|
||||
node: Id,
|
||||
screen: Point,
|
||||
bounds: Rectangle,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
) -> Action<Message> {
|
||||
let capability = self.allocation.unwrap();
|
||||
state.drag = None;
|
||||
state.preview = None;
|
||||
state.port_drag = None;
|
||||
state.route_drag = None;
|
||||
if !capability.clean {
|
||||
return Action::publish(Message::WorkDrag(Event::Refused(
|
||||
capability.state.epoch,
|
||||
input::FINISH_EDIT.into(),
|
||||
)))
|
||||
.and_capture();
|
||||
}
|
||||
let work = self.project.diagrams[&self.diagram].nodes[&node].element;
|
||||
let scope = match crate::allocation_drag::capture(self.project, self.diagram, work, node) {
|
||||
Ok(scope) => scope,
|
||||
Err(reason) => {
|
||||
return Action::publish(Message::WorkDrag(Event::Refused(
|
||||
capability.state.epoch,
|
||||
input::refusal(&reason),
|
||||
)))
|
||||
.and_capture();
|
||||
}
|
||||
};
|
||||
let frozen = input::Frozen::capture(self.project, &scope, view, routed, self.zoom);
|
||||
let press = Arc::new(Press {
|
||||
stamp: Stamp {
|
||||
context: self.context,
|
||||
epoch: capability.state.epoch,
|
||||
snapshot: capability.snapshot,
|
||||
bounds,
|
||||
pan: self.pan,
|
||||
zoom: self.zoom,
|
||||
},
|
||||
token: Id::new_v4(),
|
||||
start: screen,
|
||||
scope,
|
||||
frozen,
|
||||
arrange: capability.state.arrange_next,
|
||||
});
|
||||
state.work = Some(press.clone());
|
||||
Action::publish(Message::WorkDrag(Event::Press(press))).and_capture()
|
||||
}
|
||||
pub(super) fn work_lifetime(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
) -> Option<Action<Message>> {
|
||||
let press = state.work.as_ref()?;
|
||||
let cancel = self
|
||||
.allocation
|
||||
.is_none_or(|c| !c.clean || c.state.epoch != press.stamp.epoch)
|
||||
|| press.stamp.context != self.context
|
||||
|| press.stamp.bounds != bounds
|
||||
|| press.stamp.pan != self.pan
|
||||
|| press.stamp.zoom != self.zoom
|
||||
|| matches!(
|
||||
event,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused)
|
||||
| canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
| canvas::Event::Mouse(mouse::Event::ButtonPressed(
|
||||
mouse::Button::Middle | mouse::Button::Right
|
||||
))
|
||||
| canvas::Event::Mouse(mouse::Event::WheelScrolled { .. })
|
||||
)
|
||||
|| matches!(event, canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. })
|
||||
if *key == iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape));
|
||||
if cancel {
|
||||
let token = press.token;
|
||||
state.work = None;
|
||||
state.pointer = None;
|
||||
return Some(Action::publish(Message::WorkDrag(Event::Cancel(token))).and_capture());
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate passive layer: frozen target labels remain above all candidate card
|
||||
/// text. It does not acquire input or use a second independent semantic scene.
|
||||
pub struct WorkFeedback<'a>(pub DiagramCanvas<'a>);
|
||||
impl canvas::Program<Message> for WorkFeedback<'_> {
|
||||
type State = ();
|
||||
fn draw(
|
||||
&self,
|
||||
_: &(),
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let Some(capability) = self.0.allocation else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(feedback) = &capability.state.feedback else {
|
||||
return vec![];
|
||||
};
|
||||
if feedback.press.stamp.epoch != capability.state.epoch
|
||||
|| feedback.press.stamp.context != self.0.context
|
||||
|| feedback.press.stamp.bounds != bounds
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
let mut frame = Frame::new(renderer, bounds.size());
|
||||
let color = if feedback.refused {
|
||||
Color::from_rgb8(173, 66, 41)
|
||||
} else {
|
||||
style::TEAL
|
||||
};
|
||||
for index in &feedback.targets {
|
||||
let target = &feedback.press.frozen.targets[*index];
|
||||
let region = target.region;
|
||||
let top = Point::new(
|
||||
region.x * self.0.zoom + self.0.pan.x,
|
||||
region.y * self.0.zoom + self.0.pan.y,
|
||||
);
|
||||
frame.stroke(
|
||||
&Path::rectangle(
|
||||
top,
|
||||
iced::Size::new(region.width * self.0.zoom, region.height * self.0.zoom),
|
||||
),
|
||||
Stroke::default().with_color(color).with_width(3.),
|
||||
);
|
||||
let label = target.label;
|
||||
let top = Point::new(
|
||||
label.position.x * self.0.zoom + self.0.pan.x,
|
||||
label.position.y * self.0.zoom + self.0.pan.y,
|
||||
);
|
||||
frame.fill_rectangle(
|
||||
top,
|
||||
iced::Size::new(
|
||||
label.size.width * self.0.zoom,
|
||||
label.size.height * self.0.zoom,
|
||||
),
|
||||
Color::WHITE,
|
||||
);
|
||||
frame.fill_text(Text {
|
||||
content: target.name.clone(),
|
||||
position: top,
|
||||
size: (16. * self.0.zoom).into(),
|
||||
color,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
vec![frame.into_geometry()]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
mod allocation_drag;
|
||||
mod allocation_input;
|
||||
mod app;
|
||||
mod behavior;
|
||||
mod connection_edit;
|
||||
|
||||
@@ -284,7 +284,7 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.padding([4, 16])
|
||||
.style(style::panel);
|
||||
let root: View<'_, Message> = column![header, task_nav, body, footer].into();
|
||||
let root = crate::allocation_input::root(app, column![header, task_nav, body, footer]);
|
||||
if app.architecture.as_ref().is_some_and(|s| s.modal()) {
|
||||
return modal_width(
|
||||
root,
|
||||
@@ -414,7 +414,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.align_y(iced::Center);
|
||||
if let Some(id) = app.active_diagram {
|
||||
chooser = chooser.push(action("View properties", Message::Select(id)));
|
||||
controls = controls.push(action("Arrange", Message::Command(Command::Layout(id))));
|
||||
controls = controls.push(action("Auto-layout", Message::Command(Command::Layout(id))));
|
||||
}
|
||||
let toolbar = column![chooser, controls].spacing(6);
|
||||
let projection = app
|
||||
@@ -442,6 +442,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.align_y(iced::Center);
|
||||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||||
let program = DiagramCanvas {
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
diagram: id,
|
||||
@@ -462,14 +463,22 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
widget::canvas(crate::diagram::PortLabels(program))
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
widget::canvas(crate::diagram::WorkFeedback(program))
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
];
|
||||
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
|
||||
scene = scene.push(editor);
|
||||
}
|
||||
row![
|
||||
widget::sensor(scene)
|
||||
.on_show(Message::CanvasResized)
|
||||
.on_resize(Message::CanvasResized),
|
||||
widget::sensor(
|
||||
container(scene)
|
||||
.id(crate::allocation_input::CANVAS_ID)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
)
|
||||
.on_show(Message::CanvasResized)
|
||||
.on_resize(Message::CanvasResized),
|
||||
crate::app::architecture_authoring::palette(app)
|
||||
]
|
||||
.height(Fill)
|
||||
@@ -500,6 +509,27 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
if app.active_diagram.is_some() {
|
||||
tools = tools.push(modes);
|
||||
}
|
||||
if projection == Some(ProjectionKind::Allocation) {
|
||||
tools = tools.push(
|
||||
row![
|
||||
action(
|
||||
&format!(
|
||||
"{} · {}",
|
||||
if app.allocation_drag.arrange_next {
|
||||
"Arrange next drag: armed"
|
||||
} else {
|
||||
"Arrange next drag"
|
||||
},
|
||||
crate::allocation_input::ARRANGE_SHORTCUT
|
||||
),
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext)
|
||||
),
|
||||
label("Drag work into a performer to assign · out to remove assignment")
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
}
|
||||
if let Some(diagram) = app.active_diagram {
|
||||
let view = oa_core::projection::project_view(app.project(), diagram);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
|
||||
@@ -511,7 +541,9 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
|
||||
});
|
||||
let hint = if let Some(session) = &app.connection {
|
||||
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
|
||||
feedback.text.as_str()
|
||||
} else if let Some(session) = &app.connection {
|
||||
if session.source.is_some() {
|
||||
if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
|
||||
@@ -528,7 +560,9 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
} else {
|
||||
"Right-click for actions · drag to arrange · scroll to zoom"
|
||||
};
|
||||
tools = tools.push(container(label(hint)).height(30));
|
||||
// Status is outside the measured canvas. Clipping prevents long text
|
||||
// from painting over work headers or frozen target labels below it.
|
||||
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
|
||||
}
|
||||
column![container(tools).padding(14), canvas].into()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user