Integrate allocation drag candidate with guarded input and atomic history

This commit is contained in:
2026-09-12 03:47:45 -07:00
parent 0bd7886608
commit f52d81c1b9
19 changed files with 5067 additions and 20 deletions
+846
View File
@@ -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
+266
View File
@@ -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,
)
}
}
+23
View File
@@ -1,3 +1,4 @@
pub(crate) mod allocation_drag;
pub(crate) mod architecture_authoring; pub(crate) mod architecture_authoring;
#[cfg(test)] #[cfg(test)]
mod architecture_authoring_tests; mod architecture_authoring_tests;
@@ -327,6 +328,7 @@ pub struct FilePrompt {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
WorkDrag(allocation_drag::Event),
BehaviorControl(behavior::canvas_workspace::Intent), BehaviorControl(behavior::canvas_workspace::Intent),
Architecture(architecture_authoring::Event), Architecture(architecture_authoring::Event),
Home(crate::home::Event), Home(crate::home::Event),
@@ -450,6 +452,7 @@ pub enum Message {
} }
pub struct App { pub struct App {
pub allocation_drag: allocation_drag::State,
pub home: crate::home::State, pub home: crate::home::State,
pub recovery: crate::recovery::State, pub recovery: crate::recovery::State,
recent_target: Option<PathBuf>, recent_target: Option<PathBuf>,
@@ -557,6 +560,7 @@ impl App {
let explorer_index = crate::explorer::Index::new(&project, layer); let explorer_index = crate::explorer::Index::new(&project, layer);
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect(); let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
let mut app = Self { let mut app = Self {
allocation_drag: Default::default(),
home: Default::default(), home: Default::default(),
recovery: Default::default(), recovery: Default::default(),
recent_target: None, recent_target: None,
@@ -1060,11 +1064,21 @@ impl App {
window::close_requests().map(|id| Message::Request(Pending::Close(id))), window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)), window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self), 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( behavior::creation::subscription(
self.behavior.creation.has_pending() || self.behavior.source_active(), self.behavior.creation.has_pending() || self.behavior.source_active(),
) )
.map(|()| Message::Escape), .map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| { 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 { let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key, key,
modifiers: mods, modifiers: mods,
@@ -1108,6 +1122,11 @@ impl App {
]) ])
} }
pub fn update(&mut self, message: Message) -> Task<Message> { 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 { if let Message::BehaviorControl(intent) = message {
return self.behavior_control(intent); return self.behavior_control(intent);
} }
@@ -1118,6 +1137,9 @@ impl App {
); );
let before = self.camera_boundary(); let before = self.camera_boundary();
let task = self.update_inner(message); let task = self.update_inner(message);
if self.allocation_boundary() != allocation_before {
self.allocation_retire();
}
if !camera && self.camera_boundary() != before { if !camera && self.camera_boundary() != before {
self.camera_epoch = self.camera_epoch.wrapping_add(1); self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch; self.camera_boundary_epoch = self.canvas_epoch;
@@ -1516,6 +1538,7 @@ impl App {
} }
fn handle(&mut self, message: Message) -> Task<Message> { fn handle(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::WorkDrag(event) => return self.allocation_event(event),
Message::BehaviorControl(intent) => return self.behavior_control(intent), Message::BehaviorControl(intent) => return self.behavior_control(intent),
Message::Architecture(event) => return self.architecture_event(event), Message::Architecture(event) => return self.architecture_event(event),
Message::Home(event) => return self.home_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, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
let mut state = crate::diagram::State::default(); let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed { 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, editable: true,
placing: true, placing: true,
connection: None, connection: None,
allocation: None,
}; };
let mut state = crate::diagram::State::default(); let mut state = crate::diagram::State::default();
let action = canvas let action = canvas
@@ -306,6 +307,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.)); let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.)); let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
+115 -13
View File
@@ -34,9 +34,13 @@ mod view_options_tests;
#[path = "diagram/port_labels.rs"] #[path = "diagram/port_labels.rs"]
mod port_labels; mod port_labels;
pub use port_labels::PortLabels; pub use port_labels::PortLabels;
#[path = "diagram/work_drag.rs"]
mod work_drag;
pub use work_drag::WorkFeedback;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct DiagramCanvas<'a> { pub struct DiagramCanvas<'a> {
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
pub project: &'a Project, pub project: &'a Project,
pub view_index: Option<&'a oa_core::view_options::ViewIndex>, pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
pub diagram: Id, pub diagram: Id,
@@ -51,6 +55,8 @@ pub struct DiagramCanvas<'a> {
} }
#[derive(Default)] #[derive(Default)]
pub struct State { pub struct State {
work: Option<Arc<crate::app::allocation_drag::Press>>,
work_cache: RefCell<Option<Arc<Scene>>>,
pointer: Option<Point>, pointer: Option<Point>,
keyboard_focus: bool, keyboard_focus: bool,
drag: Option<(Id, Point, Position)>, drag: Option<(Id, Point, Position)>,
@@ -64,6 +70,7 @@ pub struct State {
cache: RefCell<Option<CachedScene>>, cache: RefCell<Option<CachedScene>>,
} }
struct Scene { struct Scene {
work: Option<Arc<crate::app::allocation_drag::Preview>>,
view: ProjectedView, view: ProjectedView,
routed: Arc<RoutedView>, routed: Arc<RoutedView>,
} }
@@ -450,6 +457,26 @@ impl DiagramCanvas<'_> {
} }
} }
fn scene(&self, state: &State) -> Arc<Scene> { 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) { let preview = if state.context == Some(self.context) {
state.preview state.preview
} else { } else {
@@ -496,7 +523,11 @@ impl DiagramCanvas<'_> {
route_diagram(self.project, &diagram, &view) 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 { *state.cache.borrow_mut() = Some(CachedScene {
context: self.context, context: self.context,
preview, preview,
@@ -527,7 +558,11 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
bounds: Rectangle, bounds: Rectangle,
cursor: mouse::Cursor, cursor: mouse::Cursor,
) -> Option<Action<Message>> { ) -> Option<Action<Message>> {
if let Some(action) = self.work_lifetime(state, event, bounds) {
return Some(action);
}
if state.context != Some(self.context) { if state.context != Some(self.context) {
state.work = None;
state.pointer = None; state.pointer = None;
state.port_drag = None; state.port_drag = None;
state.connection_press = None; state.connection_press = None;
@@ -714,6 +749,22 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
let scene = self.scene(state); let scene = self.scene(state);
let view = &scene.view; let view = &scene.view;
let world = self.world(p); 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 { if self.placing && self.editable {
return Some( return Some(
Action::publish(Message::Architecture( Action::publish(Message::Architecture(
@@ -795,6 +846,16 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::publish(Message::Select(port.element)).and_capture()); return Some(Action::publish(Message::Select(port.element)).and_capture());
} }
if let Some(id) = self.hit(view, p) { 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 let position = self
.project .project
.diagrams .diagrams
@@ -833,6 +894,18 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
} }
} }
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { 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?; let p = local?;
if let Some((button, start)) = state.pan_start { if let Some((button, start)) = state.pan_start {
state.pan_start = Some((button, p)); state.pan_start = Some((button, p));
@@ -865,6 +938,18 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
None None
} }
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { 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 let Some((button, start)) = state.pan_start {
if button == mouse::Button::Middle { if button == mouse::Button::Middle {
return Some(Action::capture()); return Some(Action::capture());
@@ -1011,6 +1096,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
f.translate(self.pan); f.translate(self.pan);
f.scale(self.zoom); f.scale(self.zoom);
let scene = self.scene(state); let scene = self.scene(state);
let project = scene
.work
.as_ref()
.map_or(self.project, |p| p.semantic.project());
let view = &scene.view; let view = &scene.view;
let containers = view let containers = view
.nodes .nodes
@@ -1019,10 +1108,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
for n in &view.nodes { for n in &view.nodes {
if containers.contains(&n.id) { 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 { for edge in &view.edges {
let Some(routed) = scene.routed.edges.get(&edge.id) else { let Some(routed) = scene.routed.edges.get(&edge.id) else {
continue; continue;
@@ -1045,7 +1134,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
} else { } else {
Color::from_rgb8(144, 158, 173) 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 = let mut stroke =
Stroke::default() Stroke::default()
.with_color(color) .with_color(color)
@@ -1093,7 +1182,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
..Default::default() ..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) { for end in routing::association_end_geometry(relation, routed) {
if end.navigable { if end.navigable {
let [a, tip, b] = navigation_mark(end.tip, end.inward); 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 { for n in &view.nodes {
if !containers.contains(&n.id) { 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 { } else {
// Container headers stay readable when relationships traverse // Container headers stay readable when relationships traverse
// the allocation enclosure. Children remain on top. // the allocation enclosure. Children remain on top.
let mut header = n.clone(); let mut header = n.clone();
header.size.height = 62.; 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 { for port in &view.ports {
@@ -1141,17 +1230,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
style::TEAL style::TEAL
} else { } else {
style::kind_color( style::kind_color(
self.project.elements[&port.element] project.elements[&port.element]
.parent .parent
.and_then(|id| self.project.elements.get(&id)) .and_then(|id| project.elements.get(&id))
.map(|e| e.kind()) .map(|e| e.kind())
.unwrap_or(Kind::Port), .unwrap_or(Kind::Port),
) )
}; };
let origin = Point::new(port.position.x - 7., port.position.y - 7.); 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 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| { Path::new(|p| {
p.move_to(corners[0]); p.move_to(corners[0]);
p.line_to(corners[1]); p.line_to(corners[1]);
@@ -1181,7 +1270,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
if matches!(glyph, PortGlyph::Flow(_)) { if matches!(glyph, PortGlyph::Flow(_)) {
f.stroke( f.stroke(
&Path::new(|p| { &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.move_to(a);
p.line_to(b); p.line_to(b);
} }
@@ -1343,7 +1432,8 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
) -> mouse::Interaction { ) -> mouse::Interaction {
if (self.connection.is_some() || self.placing) && cursor.is_over(bounds) { if (self.connection.is_some() || self.placing) && cursor.is_over(bounds) {
mouse::Interaction::Crosshair 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.pan_start.is_some()
|| state.route_drag.is_some() || state.route_drag.is_some()
|| state.port_drag.is_some() || state.port_drag.is_some()
@@ -1900,6 +1990,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
diagram, diagram,
selected: p.id, selected: p.id,
zoom: 1., zoom: 1.,
@@ -1974,6 +2065,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
diagram, diagram,
selected: p.id, selected: p.id,
zoom: 0.6, zoom: 0.6,
@@ -2098,6 +2190,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: Some(&session), connection: Some(&session),
allocation: None,
}; };
let bounds = Rectangle::new(Point::new(260., 300.), Size::new(2400., 1600.)); let bounds = Rectangle::new(Point::new(260., 300.), Size::new(2400., 1600.));
let at = |port: &oa_core::projection::ProjectedPort| { let at = |port: &oa_core::projection::ProjectedPort| {
@@ -2171,6 +2264,7 @@ mod tests {
armed.start(&p, &view, source.element); armed.start(&p, &view, source.element);
let armed_canvas = DiagramCanvas { let armed_canvas = DiagramCanvas {
connection: Some(&armed), connection: Some(&armed),
allocation: None,
..canvas ..canvas
}; };
// Winit updates cursor coordinates through CursorMoved, not the // Winit updates cursor coordinates through CursorMoved, not the
@@ -2200,6 +2294,7 @@ mod tests {
// A tool cancellation or navigation invalidates a queued release. // A tool cancellation or navigation invalidates a queued release.
let cancelled = DiagramCanvas { let cancelled = DiagramCanvas {
connection: None, connection: None,
allocation: None,
context: Context { context: Context {
epoch: 1, epoch: 1,
..context ..context
@@ -2271,6 +2366,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
let mut state = State::default(); let mut state = State::default();
let pin = Point::new(400. * zoom + 150., -100. * zoom + 300.); let pin = Point::new(400. * zoom + 150., -100. * zoom + 300.);
@@ -2346,6 +2442,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
let mut state = State::default(); let mut state = State::default();
let pin = Point::new(350., 250.); let pin = Point::new(350., 250.);
@@ -2442,6 +2539,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
let mut state = State::default(); let mut state = State::default();
let first = canvas.scene(&state); let first = canvas.scene(&state);
@@ -2569,6 +2667,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
for end in ends { for end in ends {
assert!(svg.contains(&format!( assert!(svg.contains(&format!(
@@ -2622,6 +2721,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
let mut state = State::default(); let mut state = State::default();
let scene = canvas.scene(&state); let scene = canvas.scene(&state);
@@ -2695,6 +2795,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
assert!( assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),point(cover)+canvas.pan),Some(Message::SelectNode(id)) if id==node.id) 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, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}; };
let span = &routed.edges[&edge].points; let span = &routed.edges[&edge].points;
let pick = point(oa_core::routing::point_along_polyline(span, 0.4).unwrap()); let pick = point(oa_core::routing::point_along_polyline(span, 0.4).unwrap());
@@ -64,6 +64,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
editable: true, editable: true,
placing: false, placing: false,
connection: app.connection.as_ref(), connection: app.connection.as_ref(),
allocation: None,
} }
} }
fn screen(app: &App, id: Id) -> Point { fn screen(app: &App, id: Id) -> Point {
@@ -72,6 +72,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
editable: true, editable: true,
placing: false, placing: false,
connection: app.connection.as_ref(), connection: app.connection.as_ref(),
allocation: None,
} }
} }
fn event( fn event(
@@ -18,6 +18,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
} }
} }
@@ -58,6 +58,9 @@ impl canvas::Program<Message> for PortLabels<'_> {
bounds: Rectangle, bounds: Rectangle,
cursor: mouse::Cursor, cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> { ) -> 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 { if state.context == Some(self.0.context) && state.held_buttons != 0 {
return vec![]; return vec![];
} }
@@ -185,6 +188,7 @@ mod tests {
editable: true, editable: true,
placing: false, placing: false,
connection: None, connection: None,
allocation: None,
}) })
} }
+205
View File
@@ -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()]
}
}
+2
View File
@@ -1,4 +1,6 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod allocation_drag;
mod allocation_input;
mod app; mod app;
mod behavior; mod behavior;
mod connection_edit; mod connection_edit;
+41 -7
View File
@@ -284,7 +284,7 @@ pub fn view(app: &App) -> View<'_, Message> {
) )
.padding([4, 16]) .padding([4, 16])
.style(style::panel); .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()) { if app.architecture.as_ref().is_some_and(|s| s.modal()) {
return modal_width( return modal_width(
root, root,
@@ -414,7 +414,7 @@ fn diagram(app: &App) -> View<'_, Message> {
.align_y(iced::Center); .align_y(iced::Center);
if let Some(id) = app.active_diagram { if let Some(id) = app.active_diagram {
chooser = chooser.push(action("View properties", Message::Select(id))); 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 toolbar = column![chooser, controls].spacing(6);
let projection = app let projection = app
@@ -442,6 +442,7 @@ fn diagram(app: &App) -> View<'_, Message> {
.align_y(iced::Center); .align_y(iced::Center);
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram { let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
let program = DiagramCanvas { let program = DiagramCanvas {
allocation: Some(app.allocation_capability()),
project: app.project(), project: app.project(),
view_index: Some(app.document.view_index()), view_index: Some(app.document.view_index()),
diagram: id, diagram: id,
@@ -462,14 +463,22 @@ fn diagram(app: &App) -> View<'_, Message> {
widget::canvas(crate::diagram::PortLabels(program)) widget::canvas(crate::diagram::PortLabels(program))
.width(Fill) .width(Fill)
.height(Fill), .height(Fill),
widget::canvas(crate::diagram::WorkFeedback(program))
.width(Fill)
.height(Fill),
]; ];
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) { if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
scene = scene.push(editor); scene = scene.push(editor);
} }
row![ row![
widget::sensor(scene) widget::sensor(
.on_show(Message::CanvasResized) container(scene)
.on_resize(Message::CanvasResized), .id(crate::allocation_input::CANVAS_ID)
.width(Fill)
.height(Fill)
)
.on_show(Message::CanvasResized)
.on_resize(Message::CanvasResized),
crate::app::architecture_authoring::palette(app) crate::app::architecture_authoring::palette(app)
] ]
.height(Fill) .height(Fill)
@@ -500,6 +509,27 @@ fn diagram(app: &App) -> View<'_, Message> {
if app.active_diagram.is_some() { if app.active_diagram.is_some() {
tools = tools.push(modes); 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 { if let Some(diagram) = app.active_diagram {
let view = oa_core::projection::project_view(app.project(), diagram); let view = oa_core::projection::project_view(app.project(), diagram);
let edge = view.edges.iter().find(|edge| edge.id == app.selected); 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) .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.source.is_some() {
if session.candidates.is_empty() { if session.candidates.is_empty() {
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels" "No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
@@ -528,7 +560,9 @@ fn diagram(app: &App) -> View<'_, Message> {
} else { } else {
"Right-click for actions · drag to arrange · scroll to zoom" "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() column![container(tools).padding(14), canvas].into()
} }
@@ -0,0 +1,52 @@
# Allocation drag planner source handoff — 2026-09-11
Codex-956atf `/root/allocation_drag_planner`, delegated by root under [issue 3 comment 452](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3#issuecomment-452). This is an isolated source contribution, not integration or native acceptance. Root owns coordination, the compiler lane, review, integration and delivery.
The worktree is `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/allocation-drag-worktree-20260911`, branch `codex/allocation-drag-956atf-20260911`, based on `abffa4b3f0a859d7439103100f59805804d46e55`. Changes are uncommitted. No root-worktree source, core/schema/guide, App/diagram wiring or peer child-work implementation was edited. No Git mutation, Gitea mutation, compiler/test/native execution, Linux/WSL, VPN/network diagnosis or subagent was performed.
## Contract and implemented source
Read this worktree's `AGENTS.md` and `PROJECT_GOAL.md`, the parent's [original design](release-activity-allocation-drag-design-20260911.md), [independent review](release-activity-allocation-drag-review-20260911.md), and adopted [v2 contract](release-activity-allocation-drag-contract-v2-20260911.md). The latter three reports were read in the root worktree. User-owned specification text was not changed.
- [`allocation_drag.rs`](../crates/oa-desktop/src/allocation_drag.rs) is ordinary reusable Rust, independent of Iced and Document mutation. [`main.rs`](../crates/oa-desktop/src/main.rs) adds only `#[cfg(test)] mod allocation_drag;` at root's direction, so the unmounted component is available to real component tests without exposing unused production code or suppressing warnings.
- `capture(Project, diagram, work, placement)` validates the baseline and retains exact same-kind semantic work records S, leaf provenance L, and active-diagram placements Q. It includes folded and unplaced leaves, direct Allocation identity/metadata, RoleAssignment/RoleRealization records and effective performers. Uniform classification distinguishes Assigned(P), Unassigned and MixedOrUnresolved. Existing core duplicate-placement and cycle validation remains authoritative.
- `plan_semantics(Project, Scope, Destination, leaf_to_stable_id_map)` rechecks the captured records, refuses composite mixed/unresolved/distributed geometry, composes existing CreateRelation/EditRelation/Delete commands and runs core preparation/validation. Same-effective assignment, including role-only or mixed direct/role provenance, leaves semantics exact. Different performer changes only leaf Allocation sources or creates stable caller-allocated leaf identities. Outward retained roles produce a named refusal explaining fallback performers. No work parent or role record is edited.
- Expected semantics are constructed independently from those commands. An exact whole-Project equality gate rejects both removed records and surviving changed records, including Constraint reference cleanup, artifacts, traces/baselines, SA mapping cascades and general view-option pruning. The only cross-diagram permission is removing a stored route whose own Allocation was intentionally deleted. Core warning diagnostics are retained on the plan.
- `place(plan, requested_root_position, PresentationContext)` clones presentation only and uses the plan's candidate Project and ViewIndex. Root coordinates snap to the existing ten-unit grid, then one identical delta applies to every Q placement. Routes use `translated_routes` and the captured accepted Q delta map; one-ended pins and label offsets retain their contract. Target unfold is explicit. No placements are invented. The visible and read-only expanded moved subtree are checked for coherent geometry, unchanged relative footprint, bounded coordinates and introduced/enlarged unrelated-card overlaps. Geometry ancestry is computed before pairwise collision checks; the current check remains quadratic in projected card count, with no large-model performance claim.
- `finish(plan, live_project, placement)` rejects changed live Project values, recomputes placement and repeats full command preparation and whole-Project equality. It returns an optional stable-ID Batch. `None` is a snapped no-op. The caller must execute the final Batch once through `Document::execute`, which remains responsible for source compilation, atomic adoption and history.
## Explicit integration boundaries
These structs are data, not clean-input or gesture capabilities. No source/property/behavior-draft, modal/busy, selection, focus, camera, window, revision, document-instance or token checks are implemented here. The App must guard press, preview and release; maintain an input epoch that rejects a retired token even after Undo or a modal returns to equal values; and register/consume ordered native press/release packets. No existing selection helper or `apply()` is called by this planner. No ordinary gesture has yet been redirected from legacy MoveNode.
The actual frozen target resolver remains required: compatible visible regions, semantic ancestor precedence, incomparable overlap refusal, half-open boundaries, work-card/overlay/control/port/edge occlusion, exclusion of Q and its ghost, and outside-viewport cancellation. The planner only checks that a supplied target placement is the intended performer, was visible in the accepted projection, and its supplied label rectangle lies in that frozen header and viewport. The renderer must show the matching frozen label/outline above the ghost. `PresentationContext.viewport` must come from the actual captured camera/bounds at `zoom`; this component cannot prove that transformation or native text legibility. It uses the present canvas's 62-world-unit header band for visibility. Native low-zoom/large-header review is still required.
The caller computes requested root position from the original placement plus ordered pointer motion in the captured transform, preserving press offset and the existing three-screen-pixel drag threshold. Cache a SemanticPlan by document/revision, token, scope/provenance, mode and destination. Reuse it across pointer motion, changing only placement. Include snapped delta, target unfold and frozen geometry identity in the placement cache. All candidate drawing, ports, routes and feedback must use `plan.project()` and `plan.index()` with `placement.diagram`/`placement.view`; the accepted semantic index is insufficient after a destination change.
Mixed-layout convergence, deliberate role exit and dependency repair remain named refused continuations. The component implements no packing, automatic Fit, generic impact/draft framework or UI approval flow.
## Verification authored and actual evidence
[`allocation_drag/tests.rs`](../crates/oa-desktop/src/allocation_drag/tests.rs) contains 24 unexecuted tests against actual OA/SA Project, projection/ViewIndex, `prepare_command` and Document APIs. They cover:
- Incoming/reassign/outgoing allocation identity and metadata, unchanged parents/elements, exact active placement, inactive diagrams, source comments, source compile/reopen and one-step exact Project/SourceBundle Undo/Redo.
- Same-effective role-only and uniform mixed direct/role no-ops; explicit different performer and retained advisory role warnings; unresolved/ambiguous-role explicit targets; truthful outward role fallback refusal.
- Uniform first/second/grandchild work, folded placements, an unplaced leaf, stable per-leaf IDs, no parent Allocation, manual port anchor and existing functional-port preservation, both-ended route translation, one-ended pins/labels and other-diagram preservation.
- Mixed P/x/y refusal for both semantic and Arrange only operations; changed first-child Allocation and RoleAssignment provenance; same snapped point with different semantic target candidate/index; exact snapped no-op without losing Redo.
- A surviving Constraint mutation after core-valid Allocation deletion, artifact/manual-trace cascade, deferred SA PortAllocation/ExchangeAllocation cascade, unrequested view-option pruning and allowed own-edge route cleanup across diagrams. OA communication incompleteness remains advisory.
- Explicit collapsed-target unfold, mismatched/fabricated/clipped target labels, new/enlarged and folded-subtree collisions, preexisting nonenlarged overlap, bounds/header refusal, missing/reused IDs and duplicate placements.
- Document source-budget refusal preserving exact Project/SourceBundle/history/revision, followed by a stable-identity retry with adequate source budget.
Actual checks: root-authorized task-local `rustfmt.exe --edition 2024 --config skip_children=true` completed successfully on the three owned Rust files (syntax parsing and formatting only), and `git diff --check` found no tracked whitespace errors. No compiler, Cargo command, unit test or native workflow was run by this agent; no test pass or semantic/runtime acceptance is claimed. The tests and fixtures need root's compiler lane and independent review, then mounted/native acceptance from the v2 contract before this slice can be claimed.
Root can run the targeted component suite using the repository's configured desktop test environment with a test filter `allocation_drag::tests`; inspect the exact integrated source and relevant existing regression gates. No full test command is prescribed here because root owns the active toolchain/runtime configuration and sole compiler lane.
## Adversarial-review coverage addendum
Root reported no actionable defect from the independent source review and requested three missing regression groups. Only the test file and this addendum changed. Five new OA/SA tests bring the total to **29, all unexecuted by this agent**; the original 39,035-byte test-file prefix retains SHA256 `C1C78CECD8628EAA66172356110E2FF47A7B824D53B8D05CE9196EE8D6AB9D24`.
- Moving only x from uniform P(x,y) changes the exact intended Allocation and x placement, preserves y and stored routes/other diagrams, and projects changed ancestor grouping. A roomy ancestor placement allows reassignment and outward return to P; an overlapping regrouped ancestor refuses despite valid candidate semantics.
- A populated collapsed target admits insertion into available space while preserving all existing target placements and a nested collapsed work group. Covering existing content refuses. Separately, opening the target to expose an already-authored hidden overlap refuses even when the incoming card has room: the conservative first slice rejects newly exposed confusion as well as relocation collisions.
- Valid StateAction and Availability relations under A's lifecycle cause the real `state_owner_context` error after A→B reallocation. Tests check core diagnostics, planner refusal, and atomic Document rejection preserving Project, SourceBundle, revision, Undo and Redo. The inspected validator permits missing effective performer in this context; the tests explicitly accept outward semantic removal rather than inventing a prohibition.
Task-local rustfmt parsed/formatted only the modified test file successfully. No compilation, test/runtime/native execution or Git/index work occurred during this addendum. `allocation_drag.rs` and `main.rs` remain frozen at their original hashes; root still owns execution and acceptance.
@@ -0,0 +1,53 @@
# Allocation drag UI source handoff — 2026-09-11
Codex-956atf `/root/allocation_drag_ui`, delegated by root under issue 3's expanded UI scope (comment 472). This is an isolated, uncommitted source contribution. Root owns independent review, the compiler lane, native acceptance, integration and publication.
Worktree: `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/allocation-drag-worktree-20260911`, parent-provided branch `codex/allocation-drag-956atf-20260911`, base `abffa4b3f0a859d7439103100f59805804d46e55`. Root instructions, PROJECT_GOAL, the original independent review and adopted v2 contract were read. User-owned input, core/schema/behavior/guide sources, the frozen pure planner/tests and peer CreateContextElement logic were not edited. No compiler/Cargo/test/native execution, Git/index command, Gitea mutation, Linux/WSL, VPN operation or subdelegation occurred.
Apply the focused changes to shared files against that base; do not replace a newer root `app.rs`, `diagram.rs`, `views.rs` or their tests wholesale. In particular preserve the root canvas and peer child-work changes integrated after this worktree's base. The five existing test-helper files below have only mechanical `allocation: None` constructor additions and formatting.
## Implemented seams
- `app/allocation_drag.rs` owns clean input admission, selection without `select`/`apply`, a monotonic input epoch, accepted snapshot identity, token registration/consumption, per-leaf stable IDs, destination-keyed semantic plans and retryable status. Stamps include document instance/revision/diagram/context, input epoch, snapshot identity, exact bounds, pan and zoom. Ordinary App input retires the token before dispatch; passive updates retire it when the measured boundary changes. Keyboard/focus events retire a held gesture; modal/tool/Undo/Fit round trips cannot revive it. Old motion/release packets cannot cancel a newer token.
- `diagram/work_drag.rs` and localized `diagram.rs` wiring preserve ordered native positions and the existing three-screen-pixel threshold. Work-body press, preview and release messages can be emitted before Iced rebuilds; App processes their shared token in order. Dirty Allocation object presses are intercepted before node/port/edge selection. Non-Allocation and non-work gesture paths retain their previous implementation. Existing test constructors explicitly opt into that legacy path; new mounted tests use the production capability.
- Accepted visible performer regions, semantic ancestry, other cards, ports, route segments/handles/labels and the viewport are frozen at press. Resolution uses half-open boundaries, deepest semantic ancestry, explicit incomparable overlap refusal, and exposed-space occlusion. Collapsed targets expose only their header. Q cards and their port glyphs do not occlude their moving ghost. Outside release cancels, never unassigns. The fixed feedback overlay band is also excluded from destination hits.
- App preview owns the pure SemanticPlan and PlacementPlan. Candidate node/port drawing, relationship details and routing use that plan's Project and its own ViewIndex; the accepted semantic cache does not substitute for it. A separate passive WorkFeedback canvas paints the frozen target outline/label above candidate text. The destination/refusal text also persists in App status after release. Low zoom (work/target 16-world-unit type below 9 screen pixels) gives a readable retry refusal; no automatic Fit or packing is introduced.
- The final optional Batch goes directly through `Document::execute`, avoiding unrelated App selection/repair helpers. The pure finish gate is rerun at release. Source rejection leaves the document/history intact; clean snapped no-ops do not execute. Work remains selected after a successful commit. Arrange next drag is visibly armed, consumed on press and cleared by cancellation/lifetime changes. Existing whole-view Arrange is relabeled Auto-layout to distinguish it.
The frozen pure planner remains SHA-256 `761921A1DD608873DDECFBC138685D40A1F557BC3B619E02D90231E13814B433`. Root reported its separate warning-denied direct harness and 29 component tests passing; that evidence does not validate this UI contribution.
## Authored verification and remaining gates
`app::allocation_drag::tests` contains 19 unexecuted tests:
- Actual DiagramCanvas → App clean stationary selection; dirty property raw input/value/editor cursor and selection preservation; dirty nearby port interception; pending source/behavior, busy and modal admission.
- OA and SA ordinary incoming → reassignment → outgoing through an ordered native event batch, exact relation identity/metadata, work elements, source compile round trip and one-step Undo/Redo snapshots.
- Modal round trip, equal Fit/Undo, measured resize, Escape/unfocus, equal document replacement and actual canvas-origin change retirement; stale packets leave a newer token alone; release consumes once.
- One-shot Arrange consumption, snapped no-op preserving Redo, outside-viewport cancellation, atomic Document source-budget refusal and a fresh live-state retry.
- Semantic plans reused within/revisiting one target, distinct plans and stable relation IDs across targets, own-index projection; same snapped root point on opposite sides of a target border still rebuilds destination semantics even where conservative layout gates refuse placement.
- Semantic nested priority versus geometric peer overlap, half-open shared borders, card/overlay/port/edge controls and collapsed-header target regions.
- Role-only outward refusal with unchanged placement; same-effective arrangement without a direct Allocation; uniform folded parent drag moving its hidden child placement and assigning only semantic leaves.
Actual local validation is limited to successful scoped `rustfmt.exe --edition 2024 --config skip_children=true` parsing/formatting of the changed Rust files, source inspection and SHA-256 capture. No test pass, type-check pass, GPU behavior or native usability is claimed. The root compiler lane must run the 19 mounted tests, planner regressions and relevant existing App/diagram/architecture regressions, then independently review the integration.
Native acceptance remains required at 1100×700 and wider for normal empty/incoming/between/outgoing and uniform composite tasks; exact highlighted destination, candidate ports/routes and target readability; source comments/save/reopen; dirty text-control focus retention (the authored test checks editor cursor data, not native widget focus); keyboard activation and Escape; source refusal; target occlusion and cancellation. The renderer's label sizing and the low-zoom readability threshold are unverified heuristics. Frozen target outlining does not claim large-model layout usability. Mixed/distributed layout, role exit and collateral repair deliberately retain the planner's named refusals.
## Frozen changed Rust sources
| Path relative to worktree | SHA-256 |
| --- | --- |
| `crates/oa-desktop/src/app/allocation_drag.rs` | `40A57B54334477B5940C9DEC1E1F9E72AC2091672B2B352DDE0404AB58E883ED` |
| `crates/oa-desktop/src/app/allocation_drag_tests.rs` | `B0421511F945B76759F3162AABCCE6EE8BD988FA1FD836F450E0E1AF04A53845` |
| `crates/oa-desktop/src/diagram/work_drag.rs` | `63818235CE77788975643B7BC5B44D133F72D3461ACC4F2A172F4407C495EDB7` |
| `crates/oa-desktop/src/app.rs` | `03BA30E32FC8D7F67DABE2C6A928629D76AE856B07F2FCF6273D340128EF44CE` |
| `crates/oa-desktop/src/diagram.rs` | `A640B94EDDFFE57DCA51A9BCE8527E3FE51799CB6EFD30A69F54F26F8AAE999A` |
| `crates/oa-desktop/src/diagram/port_labels.rs` | `6643F0CBDE7A59D4772AE5284A488083E3B81F2FEFABC43619D75738F474646F` |
| `crates/oa-desktop/src/views.rs` | `F583BCF9D4A7ED90D2DE17E7D1321CB311071EC6C637DF4153F4D1D9088F44DE` |
| `crates/oa-desktop/src/main.rs` | `3C3EC86149393111275B811C28585CDB72BD99FA31FF23E746D76DEA4C3CFDF2` |
| `crates/oa-desktop/src/diagram/port_hit_tests.rs` | `C3728F127711CB6D7F80A4670A798808B6E4239DD29C273445DE81706AB6F39E` |
| `crates/oa-desktop/src/diagram/pointer_tests.rs` | `AE68E091032FE2CF71559FF7E671B3B6D657EE8279330345F0C7A57EDFA472EF` |
| `crates/oa-desktop/src/diagram/lifecycle_tests.rs` | `03DD13106F3FDD06A26699BFDFBA006FAD086A0711CAD2BA1AEB09A4E8F8656E` |
| `crates/oa-desktop/src/app/architecture_authoring_tests.rs` | `2C41129220D103030E7EF22A14409CF8D874401CB1C767462BC27B8BB219628B` |
| `crates/oa-desktop/src/app/architecture_authoring/tests.rs` | `887D877FE33C45F2834A918D33F9A834E8A46BB65E07961F629265982F55BBE9` |
This handoff itself is the fourteenth changed path. It contains no live task ledger or independent publication claim.