mirror of https://codeberg.org/topola/topola.git
feat: Add interaction stepper for route building
This commit is contained in:
parent
e2aaa932fc
commit
a4b1b3893c
|
|
@ -54,6 +54,7 @@ allowed_scopes = [
|
|||
"interactor/activity",
|
||||
"interactor/interaction",
|
||||
"interactor/interactor",
|
||||
"interactor/route_plan",
|
||||
"layout/layout",
|
||||
"layout/poly",
|
||||
"layout/via",
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ impl ViewActions {
|
|||
|
||||
pub struct PlaceActions {
|
||||
pub place_via: Switch,
|
||||
pub place_route_plan: Trigger,
|
||||
}
|
||||
|
||||
impl PlaceActions {
|
||||
|
|
@ -246,6 +247,8 @@ impl PlaceActions {
|
|||
egui::Key::P,
|
||||
)
|
||||
.into_switch(),
|
||||
place_route_plan: Action::new_keyless(tr.text("tr-menu-place-place-route-plan"))
|
||||
.into_trigger(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +261,7 @@ impl PlaceActions {
|
|||
) -> egui::InnerResponse<()> {
|
||||
ui.add_enabled_ui(have_workspace, |ui| {
|
||||
self.place_via.toggle_widget(ui, is_placing_via);
|
||||
self.place_route_plan.button(ctx, ui);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,15 @@
|
|||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use geo::point;
|
||||
use std::{
|
||||
future::Future,
|
||||
io,
|
||||
ops::ControlFlow,
|
||||
path::Path,
|
||||
sync::mpsc::{channel, Receiver, Sender},
|
||||
};
|
||||
use unic_langid::{langid, LanguageIdentifier};
|
||||
|
||||
use topola::{
|
||||
interactor::activity::InteractiveInput,
|
||||
specctra::{design::SpecctraDesign, ParseErrorContext as SpecctraLoadingError},
|
||||
};
|
||||
use topola::specctra::{design::SpecctraDesign, ParseErrorContext as SpecctraLoadingError};
|
||||
|
||||
use crate::{
|
||||
config::Config, error_dialog::ErrorDialog, menu_bar::MenuBar, status_bar::StatusBar,
|
||||
|
|
@ -37,8 +32,6 @@ pub struct App {
|
|||
error_dialog: ErrorDialog,
|
||||
|
||||
maybe_workspace: Option<Workspace>,
|
||||
|
||||
update_counter: f32,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
|
|
@ -52,7 +45,6 @@ impl Default for App {
|
|||
status_bar: StatusBar::new(),
|
||||
error_dialog: ErrorDialog::new(),
|
||||
maybe_workspace: None,
|
||||
update_counter: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,23 +64,8 @@ impl App {
|
|||
this
|
||||
}
|
||||
|
||||
/// Advances the app's state by the delta time `dt`. May call
|
||||
/// `.update_state()` more than once if the delta time is more than a multiple of
|
||||
/// the timestep.
|
||||
fn advance_state_by_dt(&mut self, interactive_input: &InteractiveInput) {
|
||||
self.update_counter += interactive_input.dt;
|
||||
|
||||
while self.update_counter >= self.menu_bar.frame_timestep {
|
||||
self.update_counter -= self.menu_bar.frame_timestep;
|
||||
|
||||
if let ControlFlow::Break(()) = self.update_state(interactive_input) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the app's state by a single step.
|
||||
fn update_state(&mut self, interactive_input: &InteractiveInput) -> ControlFlow<()> {
|
||||
fn update_state(&mut self) {
|
||||
// If a new design has been loaded from a file, create a new workspace
|
||||
// with the design. Or handle the error if there was a failure to do so.
|
||||
if let Ok(data) = self.content_channel.1.try_recv() {
|
||||
|
|
@ -128,16 +105,6 @@ impl App {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(workspace) = &mut self.maybe_workspace {
|
||||
return workspace.update_state(
|
||||
&self.translator,
|
||||
&mut self.error_dialog,
|
||||
interactive_input,
|
||||
);
|
||||
}
|
||||
|
||||
ControlFlow::Break(())
|
||||
}
|
||||
|
||||
/// Update the title displayed on the application window's frame to show the
|
||||
|
|
@ -213,22 +180,7 @@ impl eframe::App for App {
|
|||
self.maybe_workspace.as_mut(),
|
||||
);
|
||||
|
||||
let pointer_pos = self.viewport.transform.inverse()
|
||||
* ctx.input(|i| i.pointer.latest_pos().unwrap_or_default());
|
||||
|
||||
self.advance_state_by_dt(&InteractiveInput {
|
||||
pointer_pos: point! {x: pointer_pos.x as f64, y: pointer_pos.y as f64},
|
||||
dt: ctx.input(|i| i.stable_dt),
|
||||
});
|
||||
|
||||
self.status_bar.update(
|
||||
ctx,
|
||||
&self.translator,
|
||||
&self.viewport,
|
||||
self.maybe_workspace
|
||||
.as_ref()
|
||||
.and_then(|w| w.interactor.maybe_activity().as_ref()),
|
||||
);
|
||||
self.update_state();
|
||||
|
||||
if self.menu_bar.show_appearance_panel {
|
||||
if let Some(workspace) = &mut self.maybe_workspace {
|
||||
|
|
@ -241,10 +193,21 @@ impl eframe::App for App {
|
|||
let _viewport_rect = self.viewport.update(
|
||||
&self.config,
|
||||
ctx,
|
||||
&self.translator,
|
||||
&self.menu_bar,
|
||||
&mut self.error_dialog,
|
||||
self.maybe_workspace.as_mut(),
|
||||
);
|
||||
|
||||
self.status_bar.update(
|
||||
ctx,
|
||||
&self.translator,
|
||||
&self.viewport,
|
||||
self.maybe_workspace
|
||||
.as_ref()
|
||||
.and_then(|w| w.interactor.maybe_activity().as_ref()),
|
||||
);
|
||||
|
||||
self.update_locale();
|
||||
self.update_title(ctx);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use topola::{
|
|||
execution::Command, invoker::InvokerError, selection::Selection, AutorouterOptions,
|
||||
},
|
||||
board::AccessMesadata,
|
||||
interactor::{interaction::InteractionStepper, route_plan::RoutePlan},
|
||||
router::RouterOptions,
|
||||
specctra::{design::SpecctraDesign, ParseError, ParseErrorContext as SpecctraLoadingError},
|
||||
};
|
||||
|
|
@ -303,16 +304,13 @@ impl MenuBar {
|
|||
workspace
|
||||
.interactor
|
||||
.schedule(op(selection, self.autorouter_options));
|
||||
Ok::<(), InvokerError>(())
|
||||
};
|
||||
if actions.edit.remove_bands.consume_key_triggered(ctx, ui) {
|
||||
schedule(|selection, _| {
|
||||
Command::RemoveBands(selection.band_selection)
|
||||
})?;
|
||||
schedule(|selection, _| Command::RemoveBands(selection.band_selection));
|
||||
} else if actions.route.autoroute.consume_key_triggered(ctx, ui) {
|
||||
schedule(|selection, opts| {
|
||||
Command::Autoroute(selection.pin_selection, opts)
|
||||
})?;
|
||||
});
|
||||
} else if actions
|
||||
.inspect
|
||||
.compare_detours
|
||||
|
|
@ -320,7 +318,7 @@ impl MenuBar {
|
|||
{
|
||||
schedule(|selection, opts| {
|
||||
Command::CompareDetours(selection.pin_selection, opts)
|
||||
})?;
|
||||
});
|
||||
} else if actions
|
||||
.inspect
|
||||
.measure_length
|
||||
|
|
@ -328,7 +326,18 @@ impl MenuBar {
|
|||
{
|
||||
schedule(|selection, _| {
|
||||
Command::MeasureLength(selection.band_selection)
|
||||
})?;
|
||||
});
|
||||
} else if actions
|
||||
.place
|
||||
.place_route_plan
|
||||
.consume_key_triggered(ctx, ui)
|
||||
{
|
||||
if let Some(active_layer) = workspace.appearance_panel.active_layer {
|
||||
self.is_placing_via = false;
|
||||
workspace.interactor.interact(InteractionStepper::RoutePlan(
|
||||
RoutePlan::new(active_layer),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,8 +104,8 @@ impl Overlay {
|
|||
.filter_map(|node| {
|
||||
board
|
||||
.layout()
|
||||
.center_of_compoundless_node(node)
|
||||
.map(|pos| (node, pos))
|
||||
.apex_of_compoundless_node(node, active_layer)
|
||||
.map(|(_, pos)| (node, pos))
|
||||
})
|
||||
.map(|(idx, pos)| TrianVertex {
|
||||
idx,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use geo::{CoordsIter, Point, Polygon};
|
||||
use geo::{CoordsIter, LineString, Point, Polygon};
|
||||
use rstar::AABB;
|
||||
use topola::{
|
||||
geometry::{primitive::PrimitiveShape, shape::AccessShape},
|
||||
|
|
@ -89,6 +89,23 @@ impl<'a> Painter<'a> {
|
|||
)
|
||||
}
|
||||
|
||||
pub fn paint_linestring(&mut self, linestring: &LineString, color: egui::epaint::Color32) {
|
||||
self.ui.painter().add(egui::Shape::line(
|
||||
linestring
|
||||
.exterior_coords_iter()
|
||||
.map(|coords| {
|
||||
self.transform
|
||||
.mul_pos([coords.x as f32, -coords.y as f32].into())
|
||||
})
|
||||
.collect(),
|
||||
egui::epaint::PathStroke {
|
||||
width: 5.0,
|
||||
color: egui::epaint::ColorMode::Solid(color),
|
||||
kind: egui::epaint::StrokeKind::Inside,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
pub fn paint_polygon(&mut self, polygon: &Polygon, color: egui::epaint::Color32) {
|
||||
self.ui.painter().add(egui::Shape::convex_polygon(
|
||||
polygon
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use core::ops::ControlFlow;
|
||||
use geo::point;
|
||||
use petgraph::{
|
||||
data::DataMap,
|
||||
|
|
@ -23,18 +24,27 @@ use topola::{
|
|||
},
|
||||
geometry::{shape::AccessShape, GenericNode},
|
||||
graph::MakeRef,
|
||||
interactor::{
|
||||
activity::{ActivityStepper, InteractiveEvent, InteractiveInput},
|
||||
interaction::InteractionStepper,
|
||||
},
|
||||
layout::{poly::MakePolygon, via::ViaWeight},
|
||||
math::{Circle, RotationSense},
|
||||
router::navmesh::NavnodeIndex,
|
||||
};
|
||||
|
||||
use crate::{config::Config, menu_bar::MenuBar, painter::Painter, workspace::Workspace};
|
||||
use crate::{
|
||||
config::Config, error_dialog::ErrorDialog, menu_bar::MenuBar, painter::Painter,
|
||||
translator::Translator, workspace::Workspace,
|
||||
};
|
||||
|
||||
pub struct Viewport {
|
||||
pub transform: egui::emath::TSTransform,
|
||||
/// how much should a single arrow key press scroll
|
||||
pub kbd_scroll_delta_factor: f32,
|
||||
pub scheduled_zoom_to_fit: bool,
|
||||
|
||||
update_counter: f32,
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
|
|
@ -43,6 +53,7 @@ impl Viewport {
|
|||
transform: egui::emath::TSTransform::new([0.0, 0.0].into(), 0.01),
|
||||
kbd_scroll_delta_factor: 5.0,
|
||||
scheduled_zoom_to_fit: false,
|
||||
update_counter: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +61,9 @@ impl Viewport {
|
|||
&mut self,
|
||||
config: &Config,
|
||||
ctx: &egui::Context,
|
||||
tr: &Translator,
|
||||
menu_bar: &MenuBar,
|
||||
error_dialog: &mut ErrorDialog,
|
||||
maybe_workspace: Option<&mut Workspace>,
|
||||
) -> egui::Rect {
|
||||
egui::CentralPanel::default()
|
||||
|
|
@ -123,11 +136,77 @@ impl Viewport {
|
|||
let mut painter = Painter::new(ui, self.transform, menu_bar.show_bboxes);
|
||||
|
||||
if let Some(workspace) = maybe_workspace {
|
||||
let latest_point = point! {x: latest_pos.x as f64, y: -latest_pos.y as f64};
|
||||
|
||||
if !workspace.interactor.maybe_activity().as_ref().map_or(true, |activity| {
|
||||
matches!(activity.maybe_status(), Some(ControlFlow::Break(..)))
|
||||
}) {
|
||||
// there is currently some activity
|
||||
let interactive_event = if response.clicked_by(egui::PointerButton::Primary) {
|
||||
Some(InteractiveEvent::PointerPrimaryButtonClicked)
|
||||
} else if response.clicked_by(egui::PointerButton::Secondary) {
|
||||
Some(InteractiveEvent::PointerSecondaryButtonClicked)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(event) = interactive_event {
|
||||
log::debug!("got {:?}", event);
|
||||
}
|
||||
// Advances the app's state by the delta time `dt`. May call
|
||||
// `.update_state()` more than once if the delta time is more than a multiple of
|
||||
// the timestep.
|
||||
let dt = ctx.input(|i| i.stable_dt);
|
||||
let active_layer = workspace.appearance_panel.active_layer;
|
||||
self.update_counter += if interactive_event.is_some() {
|
||||
// make sure we run the loop below at least once on clicks
|
||||
let mut dtx = menu_bar.frame_timestep;
|
||||
if dt > dtx {
|
||||
dtx = dt;
|
||||
}
|
||||
dtx
|
||||
} else {
|
||||
dt
|
||||
};
|
||||
while self.update_counter >= menu_bar.frame_timestep {
|
||||
self.update_counter -= menu_bar.frame_timestep;
|
||||
if let ControlFlow::Break(()) = workspace.update_state(
|
||||
tr,
|
||||
error_dialog,
|
||||
&InteractiveInput {
|
||||
active_layer,
|
||||
pointer_pos: latest_point,
|
||||
dt,
|
||||
},
|
||||
interactive_event,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Advances the app's state by the delta time `dt`. May call
|
||||
// `.update_state()` more than once if the delta time is more than a multiple of
|
||||
// the timestep.
|
||||
let dt = ctx.input(|i| i.stable_dt);
|
||||
let active_layer = workspace.appearance_panel.active_layer;
|
||||
self.update_counter += dt;
|
||||
while self.update_counter >= menu_bar.frame_timestep {
|
||||
self.update_counter -= menu_bar.frame_timestep;
|
||||
if let ControlFlow::Break(()) = workspace.update_state(
|
||||
tr,
|
||||
error_dialog,
|
||||
&InteractiveInput {
|
||||
active_layer,
|
||||
pointer_pos: point! {x: latest_pos.x as f64, y: latest_pos.y as f64},
|
||||
dt,
|
||||
},
|
||||
None,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let layers = &mut workspace.appearance_panel;
|
||||
let overlay = &mut workspace.overlay;
|
||||
let latest_point = point! {x: latest_pos.x as f64, y: -latest_pos.y as f64};
|
||||
let board = workspace.interactor.invoker().autorouter().board();
|
||||
|
||||
if response.clicked_by(egui::PointerButton::Primary) {
|
||||
if menu_bar.is_placing_via {
|
||||
workspace.interactor.execute(Command::PlaceVia(ViaWeight {
|
||||
|
|
@ -167,7 +246,10 @@ impl Viewport {
|
|||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let layers = &mut workspace.appearance_panel;
|
||||
let overlay = &mut workspace.overlay;
|
||||
let board = workspace.interactor.invoker().autorouter().board();
|
||||
|
||||
for i in (0..layers.visible.len()).rev() {
|
||||
|
|
@ -466,6 +548,10 @@ impl Viewport {
|
|||
.paint_primitive(ghost, egui::Color32::from_rgb(75, 75, 150));
|
||||
}
|
||||
|
||||
if let ActivityStepper::Interaction(InteractionStepper::RoutePlan(rp)) = activity.activity() {
|
||||
painter.paint_linestring(&rp.lines, egui::Color32::from_rgb(245, 182, 66));
|
||||
}
|
||||
|
||||
if let Some(ref navmesh) =
|
||||
activity.maybe_thetastar().map(|astar| astar.graph())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ use std::{
|
|||
|
||||
use topola::{
|
||||
autorouter::history::History,
|
||||
interactor::{activity::InteractiveInput, Interactor},
|
||||
interactor::{
|
||||
activity::{InteractiveEvent, InteractiveInput},
|
||||
Interactor,
|
||||
},
|
||||
layout::LayoutEdit,
|
||||
specctra::{design::SpecctraDesign, mesadata::SpecctraMesadata},
|
||||
};
|
||||
|
|
@ -63,6 +66,7 @@ impl Workspace {
|
|||
tr: &Translator,
|
||||
error_dialog: &mut ErrorDialog,
|
||||
interactive_input: &InteractiveInput,
|
||||
interactive_event: Option<InteractiveEvent>,
|
||||
) -> ControlFlow<()> {
|
||||
if let Ok(data) = self.history_channel.1.try_recv() {
|
||||
match data {
|
||||
|
|
@ -88,7 +92,7 @@ impl Workspace {
|
|||
}
|
||||
}
|
||||
|
||||
match self.interactor.update(interactive_input) {
|
||||
match self.interactor.update(interactive_input, interactive_event) {
|
||||
ControlFlow::Continue(()) => ControlFlow::Continue(()),
|
||||
ControlFlow::Break(Ok(())) => ControlFlow::Break(()),
|
||||
ControlFlow::Break(Err(err)) => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ tr-menu-view-frame-timestep = Frame Timestep
|
|||
|
||||
tr-menu-place = Place
|
||||
tr-menu-place-place-via = Place Via
|
||||
tr-menu-place-place-route-plan = Place Route Plan
|
||||
|
||||
tr-menu-route = Route
|
||||
tr-menu-route-autoroute = Autoroute
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||
use crate::{
|
||||
board::AccessMesadata,
|
||||
layout::{via::ViaWeight, LayoutEdit},
|
||||
stepper::Step,
|
||||
stepper::{Abort, Step},
|
||||
};
|
||||
|
||||
use super::{
|
||||
|
|
@ -110,3 +110,10 @@ impl<M: AccessMesadata> Step<Invoker<M>, String> for ExecutionStepper {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> Abort<Invoker<M>> for ExecutionStepper {
|
||||
fn abort(&mut self, context: &mut Invoker<M>) {
|
||||
// TODO: fix this
|
||||
self.finish(context);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub use specctra_core::mesadata::AccessMesadata;
|
|||
|
||||
use bimap::BiBTreeMap;
|
||||
use derive_getters::Getters;
|
||||
use geo::Point;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
|
|
@ -175,6 +176,17 @@ impl<M: AccessMesadata> Board<M> {
|
|||
self.node_to_pinname.get(node)
|
||||
}
|
||||
|
||||
/// Returns the apex belonging to a given pin, if any
|
||||
///
|
||||
/// Warning: this is very slow.
|
||||
pub fn pin_apex(&self, pin: &str, layer: usize) -> Option<(FixedDotIndex, Point)> {
|
||||
self.node_to_pinname
|
||||
.iter()
|
||||
.filter(|(_, node_pin)| *node_pin == pin)
|
||||
// this should only ever return one result
|
||||
.find_map(|(node, _)| self.layout().apex_of_compoundless_node(*node, layer))
|
||||
}
|
||||
|
||||
/// Returns the band name associated with a given band.
|
||||
pub fn band_bandname(&self, band: &BandUid) -> Option<&BandName> {
|
||||
self.band_bandname.get_by_left(band)
|
||||
|
|
|
|||
|
|
@ -25,15 +25,23 @@ use crate::{
|
|||
navmesh::{Navmesh, NavnodeIndex},
|
||||
thetastar::ThetastarStepper,
|
||||
},
|
||||
stepper::{Abort, Step},
|
||||
stepper::{Abort, OnEvent, Step},
|
||||
};
|
||||
|
||||
/// Stores the interactive input data from the user
|
||||
pub struct InteractiveInput {
|
||||
pub active_layer: Option<usize>,
|
||||
pub pointer_pos: Point,
|
||||
pub dt: f32,
|
||||
}
|
||||
|
||||
/// An event received from the user
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum InteractiveEvent {
|
||||
PointerPrimaryButtonClicked,
|
||||
PointerSecondaryButtonClicked,
|
||||
}
|
||||
|
||||
/// This is the execution context passed to the stepper on each step
|
||||
pub struct ActivityContext<'a, M> {
|
||||
pub interactive_input: &'a InteractiveInput,
|
||||
|
|
@ -75,14 +83,27 @@ impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for ActivityStepper
|
|||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> Abort<ActivityContext<'_, M>> for ActivityStepper {
|
||||
fn abort(&mut self, context: &mut ActivityContext<M>) {
|
||||
impl<M: AccessMesadata> Abort<Invoker<M>> for ActivityStepper {
|
||||
fn abort(&mut self, context: &mut Invoker<M>) {
|
||||
match self {
|
||||
ActivityStepper::Interaction(interaction) => interaction.abort(context),
|
||||
ActivityStepper::Execution(execution) => {
|
||||
execution.finish(context.invoker);
|
||||
} // TODO.
|
||||
};
|
||||
ActivityStepper::Execution(execution) => execution.abort(context),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> OnEvent<ActivityContext<'_, M>, InteractiveEvent> for ActivityStepper {
|
||||
type Output = Result<(), InteractionError>;
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
context: &mut ActivityContext<M>,
|
||||
event: InteractiveEvent,
|
||||
) -> Result<(), InteractionError> {
|
||||
match self {
|
||||
ActivityStepper::Interaction(interaction) => interaction.on_event(context, event),
|
||||
ActivityStepper::Execution(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +121,17 @@ impl ActivityStepperWithStatus {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn new_interaction(interaction: InteractionStepper) -> ActivityStepperWithStatus {
|
||||
Self {
|
||||
activity: ActivityStepper::Interaction(interaction),
|
||||
maybe_status: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activity(&self) -> &ActivityStepper {
|
||||
&self.activity
|
||||
}
|
||||
|
||||
pub fn maybe_status(&self) -> Option<ControlFlow<String>> {
|
||||
self.maybe_status.clone()
|
||||
}
|
||||
|
|
@ -118,13 +150,27 @@ impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for ActivityStepper
|
|||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> Abort<ActivityContext<'_, M>> for ActivityStepperWithStatus {
|
||||
fn abort(&mut self, context: &mut ActivityContext<M>) {
|
||||
impl<M: AccessMesadata> Abort<Invoker<M>> for ActivityStepperWithStatus {
|
||||
fn abort(&mut self, context: &mut Invoker<M>) {
|
||||
self.maybe_status = Some(ControlFlow::Break(String::from("aborted")));
|
||||
self.activity.abort(context);
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> OnEvent<ActivityContext<'_, M>, InteractiveEvent>
|
||||
for ActivityStepperWithStatus
|
||||
{
|
||||
type Output = Result<(), InteractionError>;
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
context: &mut ActivityContext<M>,
|
||||
event: InteractiveEvent,
|
||||
) -> Result<(), InteractionError> {
|
||||
self.activity.on_event(context, event)
|
||||
}
|
||||
}
|
||||
|
||||
impl GetMaybeThetastarStepper for ActivityStepperWithStatus {
|
||||
fn maybe_thetastar(&self) -> Option<&ThetastarStepper<Navmesh, f64>> {
|
||||
self.activity.maybe_thetastar()
|
||||
|
|
|
|||
|
|
@ -2,26 +2,22 @@
|
|||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
use core::ops::ControlFlow;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
autorouter::invoker::{
|
||||
GetGhosts, GetMaybeNavcord, GetMaybeThetastarStepper, GetNavmeshDebugTexts, GetObstacles,
|
||||
Invoker,
|
||||
},
|
||||
board::AccessMesadata,
|
||||
drawing::graph::PrimitiveIndex,
|
||||
geometry::primitive::PrimitiveShape,
|
||||
router::{
|
||||
navcord::Navcord,
|
||||
navmesh::{Navmesh, NavnodeIndex},
|
||||
thetastar::ThetastarStepper,
|
||||
},
|
||||
stepper::{Abort, Step},
|
||||
stepper::{Abort, OnEvent, Step},
|
||||
};
|
||||
|
||||
use super::activity::ActivityContext;
|
||||
use super::{
|
||||
activity::{ActivityContext, InteractiveEvent},
|
||||
route_plan::RoutePlan,
|
||||
};
|
||||
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum InteractionError {
|
||||
|
|
@ -30,10 +26,10 @@ pub enum InteractionError {
|
|||
}
|
||||
|
||||
pub enum InteractionStepper {
|
||||
// No interactions yet. This is only an empty skeleton for now.
|
||||
// Examples of interactions:
|
||||
// - interactively routing a track
|
||||
// - interactively moving a footprint.
|
||||
RoutePlan(RoutePlan),
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for InteractionStepper {
|
||||
|
|
@ -41,48 +37,38 @@ impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for InteractionStep
|
|||
|
||||
fn step(
|
||||
&mut self,
|
||||
_context: &mut ActivityContext<M>,
|
||||
context: &mut ActivityContext<M>,
|
||||
) -> Result<ControlFlow<String>, InteractionError> {
|
||||
Ok(ControlFlow::Break(String::from("")))
|
||||
match self {
|
||||
Self::RoutePlan(rp) => rp.step(context),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> Abort<ActivityContext<'_, M>> for InteractionStepper {
|
||||
fn abort(&mut self, _context: &mut ActivityContext<M>) {
|
||||
todo!();
|
||||
impl<M: AccessMesadata> Abort<Invoker<M>> for InteractionStepper {
|
||||
fn abort(&mut self, context: &mut Invoker<M>) {
|
||||
match self {
|
||||
Self::RoutePlan(rp) => rp.abort(context),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GetMaybeThetastarStepper for InteractionStepper {
|
||||
fn maybe_thetastar(&self) -> Option<&ThetastarStepper<Navmesh, f64>> {
|
||||
todo!()
|
||||
impl<M: AccessMesadata> OnEvent<ActivityContext<'_, M>, InteractiveEvent> for InteractionStepper {
|
||||
type Output = Result<(), InteractionError>;
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
context: &mut ActivityContext<M>,
|
||||
event: InteractiveEvent,
|
||||
) -> Result<(), InteractionError> {
|
||||
match self {
|
||||
Self::RoutePlan(rp) => rp.on_event(context, event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GetMaybeNavcord for InteractionStepper {
|
||||
fn maybe_navcord(&self) -> Option<&Navcord> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl GetGhosts for InteractionStepper {
|
||||
fn ghosts(&self) -> &[PrimitiveShape] {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl GetObstacles for InteractionStepper {
|
||||
fn obstacles(&self) -> &[PrimitiveIndex] {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl GetNavmeshDebugTexts for InteractionStepper {
|
||||
fn navnode_debug_text(&self, _navnode: NavnodeIndex) -> Option<&str> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn navedge_debug_text(&self, _navedge: (NavnodeIndex, NavnodeIndex)) -> Option<&str> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
impl GetGhosts for InteractionStepper {}
|
||||
impl GetMaybeThetastarStepper for InteractionStepper {}
|
||||
impl GetMaybeNavcord for InteractionStepper {}
|
||||
impl GetNavmeshDebugTexts for InteractionStepper {}
|
||||
impl GetObstacles for InteractionStepper {}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,14 @@ use crate::{
|
|||
Autorouter,
|
||||
},
|
||||
board::{AccessMesadata, Board},
|
||||
interactor::activity::{
|
||||
ActivityContext, ActivityError, ActivityStepperWithStatus, InteractiveInput,
|
||||
interactor::{
|
||||
activity::{
|
||||
ActivityContext, ActivityError, ActivityStepperWithStatus, InteractiveEvent,
|
||||
InteractiveInput,
|
||||
},
|
||||
stepper::{Abort, Step},
|
||||
interaction::InteractionStepper,
|
||||
},
|
||||
stepper::{Abort, OnEvent, Step},
|
||||
};
|
||||
|
||||
/// Structure that manages the invoker and activities
|
||||
|
|
@ -48,6 +52,11 @@ impl<M: AccessMesadata> Interactor<M> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Start an interaction activity
|
||||
pub fn interact(&mut self, interaction: InteractionStepper) {
|
||||
self.activity = Some(ActivityStepperWithStatus::new_interaction(interaction));
|
||||
}
|
||||
|
||||
/// Undo last command
|
||||
pub fn undo(&mut self) -> Result<(), InvokerError> {
|
||||
self.invoker.undo()
|
||||
|
|
@ -61,13 +70,7 @@ impl<M: AccessMesadata> Interactor<M> {
|
|||
/// Abort the currently running execution or activity
|
||||
pub fn abort(&mut self) {
|
||||
if let Some(ref mut activity) = self.activity.take() {
|
||||
activity.abort(&mut ActivityContext::<M> {
|
||||
interactive_input: &InteractiveInput {
|
||||
pointer_pos: [0.0, 0.0].into(),
|
||||
dt: 0.0,
|
||||
},
|
||||
invoker: &mut self.invoker,
|
||||
});
|
||||
activity.abort(&mut self.invoker);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,9 +83,25 @@ impl<M: AccessMesadata> Interactor<M> {
|
|||
pub fn update(
|
||||
&mut self,
|
||||
interactive_input: &InteractiveInput,
|
||||
interactive_event: Option<InteractiveEvent>,
|
||||
) -> ControlFlow<Result<(), ActivityError>> {
|
||||
if let Some(ref mut activity) = self.activity {
|
||||
return match activity.step(&mut ActivityContext {
|
||||
if let Some(event) = interactive_event {
|
||||
match activity.on_event(
|
||||
&mut ActivityContext {
|
||||
interactive_input,
|
||||
invoker: &mut self.invoker,
|
||||
},
|
||||
event,
|
||||
) {
|
||||
Ok(()) => ControlFlow::Continue(()),
|
||||
Err(err) => {
|
||||
self.activity = None;
|
||||
ControlFlow::Break(Err(err.into()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match activity.step(&mut ActivityContext {
|
||||
interactive_input,
|
||||
invoker: &mut self.invoker,
|
||||
}) {
|
||||
|
|
@ -95,10 +114,12 @@ impl<M: AccessMesadata> Interactor<M> {
|
|||
self.activity = None;
|
||||
ControlFlow::Break(Err(err))
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ControlFlow::Break(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the invoker
|
||||
pub fn invoker(&self) -> &Invoker<M> {
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@
|
|||
pub mod activity;
|
||||
pub mod interaction;
|
||||
mod interactor;
|
||||
pub mod route_plan;
|
||||
|
||||
pub use interactor::*;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
// SPDX-FileCopyrightText: 2025 Topola contributors
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use core::ops::ControlFlow;
|
||||
use geo::{LineString, Point};
|
||||
use rstar::AABB;
|
||||
|
||||
use crate::{
|
||||
autorouter::invoker::Invoker,
|
||||
board::AccessMesadata,
|
||||
drawing::dot::FixedDotIndex,
|
||||
geometry::shape::AccessShape as _,
|
||||
stepper::{Abort, OnEvent, Step},
|
||||
};
|
||||
|
||||
use super::{
|
||||
activity::{ActivityContext, InteractiveEvent},
|
||||
interaction::InteractionError,
|
||||
};
|
||||
|
||||
/// An interactive collector for a route
|
||||
#[derive(Debug)]
|
||||
pub struct RoutePlan {
|
||||
pub active_layer: usize,
|
||||
pub start_pin: Option<(FixedDotIndex, Point)>,
|
||||
pub end_pin: Option<(FixedDotIndex, Point)>,
|
||||
pub lines: LineString,
|
||||
}
|
||||
|
||||
fn try_select_pin<M: AccessMesadata>(
|
||||
context: &ActivityContext<M>,
|
||||
) -> Option<(usize, FixedDotIndex, Point)> {
|
||||
let active_layer = context.interactive_input.active_layer?;
|
||||
let pos = context.interactive_input.pointer_pos;
|
||||
let board = context.invoker.autorouter().board();
|
||||
let layout = board.layout();
|
||||
let bbox = AABB::from_point([pos.x(), pos.y()]);
|
||||
|
||||
// gather relevant resolved selectors
|
||||
let mut pin = None;
|
||||
let mut apex_node = None;
|
||||
for &geom in
|
||||
layout
|
||||
.drawing()
|
||||
.rtree()
|
||||
.locate_in_envelope_intersecting(&AABB::<[f64; 3]>::from_corners(
|
||||
[pos.x(), pos.y(), active_layer as f64 - f64::EPSILON],
|
||||
[pos.x(), pos.y(), active_layer as f64 + f64::EPSILON],
|
||||
))
|
||||
{
|
||||
let node = geom.data;
|
||||
if layout.node_shape(node).intersects_with_bbox(&bbox) {
|
||||
if let Some(pin_name) = board.node_pinname(&node) {
|
||||
// insert and check that we selected exactly one pin / node
|
||||
log::debug!("node {:?} -> pin {:?}", node, pin_name);
|
||||
if let Some(old_pin) = pin {
|
||||
if pin_name != old_pin {
|
||||
log::debug!(
|
||||
"rejected pin due to ambiguity: [{:?}, {:?}]",
|
||||
old_pin,
|
||||
pin_name
|
||||
);
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pin = Some(pin_name);
|
||||
let (idx, pos) = board.pin_apex(pin_name, active_layer).unwrap();
|
||||
if let Some((idx2, _)) = apex_node {
|
||||
if idx != idx2 {
|
||||
// node index is ambiguous
|
||||
log::debug!("rejected pin due to ambiguity: [{:?}, {:?}]", idx2, idx);
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
apex_node = Some((idx, pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ret = apex_node.map(|(idx, pos)| (active_layer, idx, pos));
|
||||
log::debug!("result {:?}", ret);
|
||||
ret
|
||||
}
|
||||
|
||||
impl RoutePlan {
|
||||
pub fn new(active_layer: usize) -> Self {
|
||||
log::debug!("initialized RoutePlan");
|
||||
Self {
|
||||
active_layer,
|
||||
start_pin: None,
|
||||
end_pin: None,
|
||||
lines: LineString(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn last_pos(&self) -> Option<Point> {
|
||||
self.lines.0.last().map(|i| Point(*i))
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for RoutePlan {
|
||||
type Error = InteractionError;
|
||||
|
||||
fn step(
|
||||
&mut self,
|
||||
_context: &mut ActivityContext<M>,
|
||||
) -> Result<ControlFlow<String>, InteractionError> {
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> Abort<Invoker<M>> for RoutePlan {
|
||||
fn abort(&mut self, _context: &mut Invoker<M>) {
|
||||
self.start_pin = None;
|
||||
self.end_pin = None;
|
||||
self.lines.0.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: AccessMesadata> OnEvent<ActivityContext<'_, M>, InteractiveEvent> for RoutePlan {
|
||||
type Output = Result<(), InteractionError>;
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
context: &mut ActivityContext<M>,
|
||||
event: InteractiveEvent,
|
||||
) -> Result<(), InteractionError> {
|
||||
match event {
|
||||
InteractiveEvent::PointerPrimaryButtonClicked if self.end_pin.is_none() => {
|
||||
if let Some((layer, idx, pos)) = try_select_pin(context) {
|
||||
// make sure double-click or such doesn't corrupt state
|
||||
if let Some(start_pin) = self.start_pin {
|
||||
if self.active_layer == layer && idx != start_pin.0 {
|
||||
log::debug!("clicked on end pin: {:?}", idx);
|
||||
self.lines.0.push(pos.0);
|
||||
self.end_pin = Some((idx, pos));
|
||||
}
|
||||
} else {
|
||||
log::debug!("clicked on start pin: {:?}", idx);
|
||||
self.active_layer = layer;
|
||||
self.start_pin = Some((idx, pos));
|
||||
self.lines.0.push(pos.0);
|
||||
}
|
||||
} else if let Some(last_pos) = self.last_pos() {
|
||||
let pos = context.interactive_input.pointer_pos;
|
||||
if last_pos != pos {
|
||||
self.lines.0.push(pos.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
InteractiveEvent::PointerSecondaryButtonClicked => {
|
||||
if self.end_pin.is_some() {
|
||||
log::debug!("un-clicked end pin");
|
||||
self.end_pin = None;
|
||||
self.lines.0.pop();
|
||||
}
|
||||
if !self.lines.0.is_empty() {
|
||||
self.lines.0.pop();
|
||||
if self.lines.0.is_empty() {
|
||||
log::debug!("un-clicked start pin");
|
||||
self.start_pin = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -17,9 +17,9 @@ use crate::{
|
|||
LooseDotWeight,
|
||||
},
|
||||
gear::GearIndex,
|
||||
graph::{GetMaybeNet, IsInLayer, MakePrimitive, PrimitiveIndex},
|
||||
graph::{GetMaybeNet, IsInLayer, MakePrimitive, PrimitiveIndex, PrimitiveWeight},
|
||||
loose::LooseIndex,
|
||||
primitive::{GetWeight, MakePrimitiveShape, Primitive},
|
||||
primitive::MakePrimitiveShape,
|
||||
rules::AccessRules,
|
||||
seg::{
|
||||
FixedSegIndex, FixedSegWeight, LoneLooseSegIndex, LoneLooseSegWeight, SegIndex,
|
||||
|
|
@ -296,26 +296,48 @@ impl<R: AccessRules> Layout<R> {
|
|||
.compound_members(GenericIndex::new(poly.petgraph_index()))
|
||||
}
|
||||
|
||||
pub fn node_shape(&self, index: NodeIndex) -> Shape {
|
||||
match index {
|
||||
NodeIndex::Primitive(primitive) => primitive.primitive(&self.drawing).shape().into(),
|
||||
NodeIndex::Compound(compound) => match self.drawing.compound_weight(compound) {
|
||||
CompoundWeight::Poly(_) => {
|
||||
GenericIndex::<PolyWeight>::new(compound.petgraph_index())
|
||||
fn compound_shape(&self, compound: GenericIndex<CompoundWeight>) -> Shape {
|
||||
match self.drawing.compound_weight(compound) {
|
||||
CompoundWeight::Poly(_) => GenericIndex::<PolyWeight>::new(compound.petgraph_index())
|
||||
.ref_(self)
|
||||
.shape()
|
||||
.into()
|
||||
}
|
||||
CompoundWeight::Via(_) => self
|
||||
.via(GenericIndex::<ViaWeight>::new(compound.petgraph_index()))
|
||||
.shape()
|
||||
.into(),
|
||||
},
|
||||
CompoundWeight::Via(weight) => weight.shape().into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn node_shape(&self, index: NodeIndex) -> Shape {
|
||||
match index {
|
||||
NodeIndex::Primitive(primitive) => primitive.primitive(&self.drawing).shape().into(),
|
||||
NodeIndex::Compound(compound) => self.compound_shape(compound),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a node is not a primitive part of a compound, and if yes, returns its apex and center
|
||||
pub fn apex_of_compoundless_node(
|
||||
&self,
|
||||
node: NodeIndex,
|
||||
active_layer: usize,
|
||||
) -> Option<(FixedDotIndex, Point)> {
|
||||
fn handle_fixed_dot<R: AccessRules>(
|
||||
drawing: &Drawing<CompoundWeight, CompoundEntryLabel, R>,
|
||||
index: PrimitiveIndex,
|
||||
) -> Option<(FixedDotIndex, &FixedDotWeight)> {
|
||||
let PrimitiveIndex::FixedDot(dot) = index else {
|
||||
return None;
|
||||
};
|
||||
if let GenericNode::Primitive(PrimitiveWeight::FixedDot(weight)) = drawing
|
||||
.geometry()
|
||||
.graph()
|
||||
.node_weight(dot.petgraph_index())
|
||||
.unwrap()
|
||||
{
|
||||
Some((dot, weight))
|
||||
} else {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a node is not a primitive part of a compound, and if yes, returns its center
|
||||
pub fn center_of_compoundless_node(&self, node: NodeIndex) -> Option<Point> {
|
||||
match node {
|
||||
NodeIndex::Primitive(primitive) => {
|
||||
if self
|
||||
|
|
@ -327,13 +349,30 @@ impl<R: AccessRules> Layout<R> {
|
|||
{
|
||||
return None;
|
||||
}
|
||||
match primitive.primitive(self.drawing()) {
|
||||
Primitive::FixedDot(dot) => Some(dot.weight().pos()),
|
||||
// Primitive::LooseDot(dot) => Some(dot.weight().pos()),
|
||||
_ => None,
|
||||
handle_fixed_dot(&self.drawing, primitive).map(|(dot, weight)| (dot, weight.pos()))
|
||||
}
|
||||
NodeIndex::Compound(compound) => Some(match self.drawing.compound_weight(compound) {
|
||||
CompoundWeight::Poly(_) => {
|
||||
let poly =
|
||||
GenericIndex::<PolyWeight>::new(compound.petgraph_index()).ref_(self);
|
||||
(poly.apex(), poly.shape().center())
|
||||
}
|
||||
CompoundWeight::Via(weight) => {
|
||||
let mut dots = self.drawing.geometry().compound_members(compound);
|
||||
let apex = loop {
|
||||
// this returns None if the via is not present on this layer
|
||||
let (entry_label, dot) = dots.next()?;
|
||||
if entry_label == CompoundEntryLabel::NotInConvexHull {
|
||||
if let Some((dot, weight)) = handle_fixed_dot(&self.drawing, dot) {
|
||||
if weight.layer() == active_layer {
|
||||
break dot;
|
||||
}
|
||||
}
|
||||
NodeIndex::Compound(_) => Some(self.node_shape(node).center()),
|
||||
}
|
||||
};
|
||||
(apex, weight.shape().center())
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,15 +12,10 @@ use crate::{
|
|||
band::BandTermsegIndex,
|
||||
dot::FixedDotIndex,
|
||||
graph::{MakePrimitive, PrimitiveIndex},
|
||||
head::Head,
|
||||
primitive::MakePrimitiveShape,
|
||||
rules::AccessRules,
|
||||
},
|
||||
geometry::{
|
||||
primitive::PrimitiveShape,
|
||||
shape::{AccessShape, MeasureLength},
|
||||
},
|
||||
graph::MakeRef,
|
||||
geometry::{primitive::PrimitiveShape, shape::AccessShape},
|
||||
layout::{Layout, LayoutEdit},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use std::ops::ControlFlow;
|
||||
use core::ops::ControlFlow;
|
||||
|
||||
/// This trait represents a linearly advanceable state whose advancement may
|
||||
/// break or fail with many different return values, and to which part of
|
||||
|
|
@ -52,3 +52,10 @@ pub trait Abort<C> {
|
|||
/// Abort the stepper.
|
||||
fn abort(&mut self, context: &mut C);
|
||||
}
|
||||
|
||||
/// Steppers that may receive discrete events and act on them, implement this trait.
|
||||
pub trait OnEvent<Ctx, Event> {
|
||||
type Output;
|
||||
|
||||
fn on_event(&mut self, context: &mut Ctx, event: Event) -> Self::Output;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue