Integrate allocation drag candidate with guarded input and atomic history
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user