mirror of
https://gitlab.freedesktop.org/dabrain34/GstPipelineStudio.git
synced 2024-11-22 09:00:59 +00:00
graphview: can now select nodes and links
Nodes and links can be selected and deleted
This commit is contained in:
parent
39e39767a3
commit
a49ba4a32a
9 changed files with 409 additions and 93 deletions
6
TODO.md
6
TODO.md
|
@ -19,9 +19,9 @@ TODO:
|
||||||
- [x] Run the pipeline with GStreamer
|
- [x] Run the pipeline with GStreamer
|
||||||
- [x] Control the pipeline with GStreamer
|
- [x] Control the pipeline with GStreamer
|
||||||
- [x] Define the license
|
- [x] Define the license
|
||||||
- [] check that that a node accept to create a port on request (input/output)
|
- [] check that a node accept to create a port on request (input/output)
|
||||||
- [] select nodes/links with a Trait Selectable
|
- [x] select nodes/links with a Trait Selectable
|
||||||
- [] be able to remove a link by selecting it
|
- [x] be able to remove a link by selecting it
|
||||||
- [] Connect the logs to the window
|
- [] Connect the logs to the window
|
||||||
- [] Create a window for the video output
|
- [] Create a window for the video output
|
||||||
- [] Add multiple graphviews with tabs.
|
- [] Add multiple graphviews with tabs.
|
||||||
|
|
14
src/app.rs
14
src/app.rs
|
@ -232,6 +232,18 @@ impl GPSApp {
|
||||||
application.add_action(&action);
|
application.add_action(&action);
|
||||||
application.set_accels_for_action("app.save", &["<primary>s"]);
|
application.set_accels_for_action("app.save", &["<primary>s"]);
|
||||||
|
|
||||||
|
let action = gio::SimpleAction::new("delete", None);
|
||||||
|
application.set_accels_for_action("app.delete", &["<primary>d", "Delete"]);
|
||||||
|
let app_weak = self.downgrade();
|
||||||
|
action.connect_activate({
|
||||||
|
move |_, _| {
|
||||||
|
let app = upgrade_weak!(app_weak);
|
||||||
|
let graph_view = app.graphview.borrow();
|
||||||
|
graph_view.delete_selected();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
application.add_action(&action);
|
||||||
|
|
||||||
let action = gio::SimpleAction::new("quit", None);
|
let action = gio::SimpleAction::new("quit", None);
|
||||||
action.connect_activate({
|
action.connect_activate({
|
||||||
let app = application.downgrade();
|
let app = application.downgrade();
|
||||||
|
@ -437,7 +449,7 @@ impl GPSApp {
|
||||||
let app = upgrade_weak!(app_weak);
|
let app = upgrade_weak!(app_weak);
|
||||||
println!("node.request-pad-output {}", node_id);
|
println!("node.request-pad-output {}", node_id);
|
||||||
let mut node = app.graphview.borrow_mut().node(&node_id).unwrap();
|
let mut node = app.graphview.borrow_mut().node(&node_id).unwrap();
|
||||||
let port_id = app.graphview.borrow().next_port_id();
|
let port_id = app.graphview.borrow_mut().next_port_id();
|
||||||
node.add_port(port_id, "out", PortDirection::Output);
|
node.add_port(port_id, "out", PortDirection::Output);
|
||||||
pop_menu.unparent();
|
pop_menu.unparent();
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -40,8 +40,7 @@
|
||||||
<section>
|
<section>
|
||||||
<item>
|
<item>
|
||||||
<attribute name="label" translatable="yes" comments="Node menu entry delete the element">_Delete node</attribute>
|
<attribute name="label" translatable="yes" comments="Node menu entry delete the element">_Delete node</attribute>
|
||||||
<attribute name="action">app.node.delete</attribute>
|
<attribute name="action">app.delete</attribute>
|
||||||
<attribute name="accel"><primary>n</attribute>
|
|
||||||
</item>
|
</item>
|
||||||
<submenu>
|
<submenu>
|
||||||
<attribute name="label" translatable="yes" comments="Node menu entry request pad">_Request pad</attribute>
|
<attribute name="label" translatable="yes" comments="Node menu entry request pad">_Request pad</attribute>
|
||||||
|
|
|
@ -1,11 +1,29 @@
|
||||||
@define-color graphview-link #808080;
|
@define-color graphview-link #808080;
|
||||||
|
|
||||||
node-button {
|
button.node {
|
||||||
color: black;
|
color: rgb(0, 0, 255);
|
||||||
padding: 10px;
|
background: rgb(170, 255, 170);
|
||||||
border-radius: 5px;
|
}
|
||||||
transition: all 250ms ease-in;
|
|
||||||
border: 1px transparent solid;
|
button.node-selected {
|
||||||
|
border-color: rgb(255, 0, 0);
|
||||||
|
background: rgb(170, 255, 170);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.port {
|
||||||
|
color: rgb(0, 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.port-selected {
|
||||||
|
border-color: rgb(255, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.port-out {
|
||||||
|
background: rgb(255, 170, 170);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.port-in {
|
||||||
|
background: rgb(170, 170, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
graphview {
|
graphview {
|
||||||
|
|
|
@ -24,7 +24,7 @@ use xml::reader::XmlEvent as XMLREvent;
|
||||||
use xml::writer::EmitterConfig;
|
use xml::writer::EmitterConfig;
|
||||||
use xml::writer::XmlEvent as XMLWEvent;
|
use xml::writer::XmlEvent as XMLWEvent;
|
||||||
|
|
||||||
use super::{node::Node, node::NodeType, port::Port, port::PortDirection};
|
use super::{link::Link, node::Node, node::NodeType, port::Port, port::PortDirection};
|
||||||
use glib::subclass::Signal;
|
use glib::subclass::Signal;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
|
@ -41,15 +41,6 @@ use log::{error, warn};
|
||||||
|
|
||||||
use std::cell::RefMut;
|
use std::cell::RefMut;
|
||||||
use std::{cmp::Ordering, collections::HashMap, error};
|
use std::{cmp::Ordering, collections::HashMap, error};
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NodeLink {
|
|
||||||
pub id: u32,
|
|
||||||
pub node_from: u32,
|
|
||||||
pub node_to: u32,
|
|
||||||
pub port_from: u32,
|
|
||||||
pub port_to: u32,
|
|
||||||
pub active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
static GRAPHVIEW_STYLE: &str = include_str!("graphview.css");
|
static GRAPHVIEW_STYLE: &str = include_str!("graphview.css");
|
||||||
|
|
||||||
|
@ -66,7 +57,7 @@ mod imp {
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GraphView {
|
pub struct GraphView {
|
||||||
pub(super) nodes: RefCell<HashMap<u32, Node>>,
|
pub(super) nodes: RefCell<HashMap<u32, Node>>,
|
||||||
pub(super) links: RefCell<HashMap<u32, NodeLink>>,
|
pub(super) links: RefCell<HashMap<u32, Link>>,
|
||||||
pub(super) current_node_id: Cell<u32>,
|
pub(super) current_node_id: Cell<u32>,
|
||||||
pub(super) current_port_id: Cell<u32>,
|
pub(super) current_port_id: Cell<u32>,
|
||||||
pub(super) current_link_id: Cell<u32>,
|
pub(super) current_link_id: Cell<u32>,
|
||||||
|
@ -152,8 +143,27 @@ mod imp {
|
||||||
obj.emit_by_name("port-right-clicked", &[&port.id(), &node.id(), &graphene::Point::new(x as f32,y as f32)]).expect("unable to send signal");
|
obj.emit_by_name("port-right-clicked", &[&port.id(), &node.id(), &graphene::Point::new(x as f32,y as f32)]).expect("unable to send signal");
|
||||||
} else if let Some(target) = target.ancestor(Node::static_type()) {
|
} else if let Some(target) = target.ancestor(Node::static_type()) {
|
||||||
let node = target.dynamic_cast::<Node>().expect("click event is not on the Node");
|
let node = target.dynamic_cast::<Node>().expect("click event is not on the Node");
|
||||||
|
widget.unselect_all();
|
||||||
|
node.set_selected(true);
|
||||||
obj.emit_by_name("node-right-clicked", &[&node.id(), &graphene::Point::new(x as f32,y as f32)]).expect("unable to send signal");
|
obj.emit_by_name("node-right-clicked", &[&node.id(), &graphene::Point::new(x as f32,y as f32)]).expect("unable to send signal");
|
||||||
}
|
}
|
||||||
|
} else if gesture.current_button() == BUTTON_PRIMARY {
|
||||||
|
let widget = drag_controller.widget().expect("click event has no widget")
|
||||||
|
.dynamic_cast::<Self::Type>()
|
||||||
|
.expect("click event is not on the GraphView");
|
||||||
|
let target = widget.pick(x, y, gtk::PickFlags::DEFAULT).expect("port pick() did not return a widget");
|
||||||
|
if let Some(target) = target.ancestor(Port::static_type()) {
|
||||||
|
let port = target.dynamic_cast::<Port>().expect("click event is not on the Node");
|
||||||
|
widget.unselect_all();
|
||||||
|
port.toggle_selected();
|
||||||
|
} else if let Some(target) = target.ancestor(Node::static_type()) {
|
||||||
|
let node = target.dynamic_cast::<Node>().expect("click event is not on the Node");
|
||||||
|
widget.unselect_all();
|
||||||
|
node.toggle_selected();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
widget.point_on_link(&graphene::Point::new(x.floor() as f32,y.floor() as f32));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -181,14 +191,15 @@ mod imp {
|
||||||
std::mem::swap(&mut node_from, &mut node_to);
|
std::mem::swap(&mut node_from, &mut node_to);
|
||||||
std::mem::swap(&mut port_from, &mut port_to);
|
std::mem::swap(&mut port_from, &mut port_to);
|
||||||
}
|
}
|
||||||
widget.add_link(NodeLink {
|
widget.add_link(Link::new(
|
||||||
id: widget.next_link_id(),
|
widget.next_link_id(),
|
||||||
node_from: node_from.id(),
|
node_from.id(),
|
||||||
node_to: node_to.id(),
|
node_to.id(),
|
||||||
port_from: port_from.id(),
|
port_from.id(),
|
||||||
port_to: port_to.id(),
|
port_to.id(),
|
||||||
active: true
|
true,
|
||||||
} );
|
false,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
widget.set_selected_port(None);
|
widget.set_selected_port(None);
|
||||||
} else {
|
} else {
|
||||||
|
@ -261,18 +272,21 @@ mod imp {
|
||||||
))
|
))
|
||||||
.expect("Failed to get cairo context");
|
.expect("Failed to get cairo context");
|
||||||
|
|
||||||
link_cr.set_line_width(1.5);
|
|
||||||
|
|
||||||
for link in self.links.borrow().values() {
|
for link in self.links.borrow().values() {
|
||||||
if let Some((from_x, from_y, to_x, to_y)) = self.link_coordinates(link) {
|
if let Some((from_x, from_y, to_x, to_y)) = self.link_coordinates(link) {
|
||||||
//println!("from_x: {} from_y: {} to_x: {} to_y: {}", from_x, from_y, to_x, to_y);
|
//println!("from_x: {} from_y: {} to_x: {} to_y: {}", from_x, from_y, to_x, to_y);
|
||||||
|
link_cr.set_line_width(link.thickness as f64);
|
||||||
// Use dashed line for inactive links, full line otherwise.
|
// Use dashed line for inactive links, full line otherwise.
|
||||||
if link.active {
|
if link.active {
|
||||||
link_cr.set_dash(&[], 0.0);
|
link_cr.set_dash(&[], 0.0);
|
||||||
} else {
|
} else {
|
||||||
link_cr.set_dash(&[10.0, 5.0], 0.0);
|
link_cr.set_dash(&[10.0, 5.0], 0.0);
|
||||||
}
|
}
|
||||||
|
if link.selected() {
|
||||||
|
link_cr.set_source_rgb(1.0, 0.18, 0.18);
|
||||||
|
} else {
|
||||||
|
link_cr.set_source_rgb(0.0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
link_cr.move_to(from_x, from_y);
|
link_cr.move_to(from_x, from_y);
|
||||||
link_cr.line_to(to_x, to_y);
|
link_cr.line_to(to_x, to_y);
|
||||||
|
@ -293,13 +307,12 @@ mod imp {
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// `Some((from_x, from_y, to_x, to_y))` if all objects the links refers to exist as widgets.
|
/// `Some((from_x, from_y, to_x, to_y))` if all objects the links refers to exist as widgets.
|
||||||
fn link_coordinates(&self, link: &NodeLink) -> Option<(f64, f64, f64, f64)> {
|
pub fn link_coordinates(&self, link: &Link) -> Option<(f64, f64, f64, f64)> {
|
||||||
let nodes = self.nodes.borrow();
|
let nodes = self.nodes.borrow();
|
||||||
|
|
||||||
// For some reason, gtk4::WidgetExt::translate_coordinates gives me incorrect values,
|
let from_node = nodes.get(&link.node_from)?;
|
||||||
// so we manually calculate the needed offsets here.
|
let from_port = from_node.port(&link.port_from)?;
|
||||||
|
|
||||||
let from_port = &nodes.get(&link.node_from)?.port(&link.port_from)?;
|
|
||||||
let gtk::Allocation {
|
let gtk::Allocation {
|
||||||
x: mut fx,
|
x: mut fx,
|
||||||
y: mut fy,
|
y: mut fy,
|
||||||
|
@ -307,28 +320,29 @@ mod imp {
|
||||||
height: fh,
|
height: fh,
|
||||||
} = from_port.allocation();
|
} = from_port.allocation();
|
||||||
|
|
||||||
let from_node = from_port
|
|
||||||
.ancestor(Node::static_type())
|
|
||||||
.expect("Port is not a child of a node");
|
|
||||||
let gtk::Allocation { x: fnx, y: fny, .. } = from_node.allocation();
|
let gtk::Allocation { x: fnx, y: fny, .. } = from_node.allocation();
|
||||||
fx += fnx + (fw / 2);
|
|
||||||
fy += fny + (fh / 2);
|
|
||||||
|
|
||||||
let to_port = &nodes.get(&link.node_to)?.port(&link.port_to)?;
|
if let Some((port_x, port_y)) = from_port.translate_coordinates(from_node, 0.0, 0.0) {
|
||||||
|
fx += fnx + fw + port_x as i32;
|
||||||
|
fy = fny + (fh / 2) + port_y as i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
let to_node = nodes.get(&link.node_to)?;
|
||||||
|
let to_port = to_node.port(&link.port_to)?;
|
||||||
let gtk::Allocation {
|
let gtk::Allocation {
|
||||||
x: mut tx,
|
x: mut tx,
|
||||||
y: mut ty,
|
y: mut ty,
|
||||||
width: tw,
|
width: _tw,
|
||||||
height: th,
|
height: th,
|
||||||
..
|
..
|
||||||
} = to_port.allocation();
|
} = to_port.allocation();
|
||||||
let to_node = to_port
|
|
||||||
.ancestor(Node::static_type())
|
|
||||||
.expect("Port is not a child of a node");
|
|
||||||
let gtk::Allocation { x: tnx, y: tny, .. } = to_node.allocation();
|
|
||||||
tx += tnx + (tw / 2);
|
|
||||||
ty += tny + (th / 2);
|
|
||||||
|
|
||||||
|
let gtk::Allocation { x: tnx, y: tny, .. } = to_node.allocation();
|
||||||
|
if let Some((port_x, port_y)) = to_port.translate_coordinates(to_node, 0.0, 0.0) {
|
||||||
|
tx += tnx + port_x as i32;
|
||||||
|
ty = tny + (th / 2) + port_y as i32;
|
||||||
|
}
|
||||||
|
//println!("{} {} -> {} {}", fx, fy, tx, ty);
|
||||||
Some((fx.into(), fy.into(), tx.into(), ty.into()))
|
Some((fx.into(), fy.into(), tx.into(), ty.into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -460,6 +474,14 @@ impl GraphView {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn unselect_nodes(&self) {
|
||||||
|
let private = imp::GraphView::from_instance(self);
|
||||||
|
for node in private.nodes.borrow_mut().values() {
|
||||||
|
node.set_selected(false);
|
||||||
|
node.unselect_all_ports();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Port related methods
|
// Port related methods
|
||||||
pub fn add_port(
|
pub fn add_port(
|
||||||
&self,
|
&self,
|
||||||
|
@ -502,14 +524,14 @@ impl GraphView {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link related methods
|
// Link related methods
|
||||||
pub fn all_links(&self) -> Vec<NodeLink> {
|
pub fn all_links(&self) -> Vec<Link> {
|
||||||
let private = imp::GraphView::from_instance(self);
|
let private = imp::GraphView::from_instance(self);
|
||||||
let links = private.links.borrow();
|
let links = private.links.borrow();
|
||||||
let links_list: Vec<_> = links.iter().map(|(_, link)| link.clone()).collect();
|
let links_list: Vec<_> = links.iter().map(|(_, link)| link.clone()).collect();
|
||||||
links_list
|
links_list
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_link(&self, link: NodeLink) {
|
pub fn add_link(&self, link: Link) {
|
||||||
let private = imp::GraphView::from_instance(self);
|
let private = imp::GraphView::from_instance(self);
|
||||||
if !self.link_exists(&link) {
|
if !self.link_exists(&link) {
|
||||||
private.links.borrow_mut().insert(link.id, link);
|
private.links.borrow_mut().insert(link.id, link);
|
||||||
|
@ -535,6 +557,35 @@ impl GraphView {
|
||||||
self.queue_draw();
|
self.queue_draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn unselect_links(&self) {
|
||||||
|
let private = imp::GraphView::from_instance(self);
|
||||||
|
for link in private.links.borrow_mut().values() {
|
||||||
|
link.set_selected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn point_on_link(&self, point: &graphene::Point) -> Option<Link> {
|
||||||
|
let private = imp::GraphView::from_instance(self);
|
||||||
|
self.unselect_all();
|
||||||
|
for link in private.links.borrow_mut().values() {
|
||||||
|
if let Some((from_x, from_y, to_x, to_y)) = private.link_coordinates(link) {
|
||||||
|
let quad = graphene::Quad::new(
|
||||||
|
&graphene::Point::new(from_x as f32, from_y as f32 - link.thickness as f32),
|
||||||
|
&graphene::Point::new(to_x as f32, to_y as f32 - link.thickness as f32),
|
||||||
|
&graphene::Point::new(to_x as f32, to_y as f32 + link.thickness as f32),
|
||||||
|
&graphene::Point::new(from_x as f32, from_y as f32 + link.thickness as f32),
|
||||||
|
);
|
||||||
|
if quad.contains(point) {
|
||||||
|
link.toggle_selected();
|
||||||
|
self.queue_draw();
|
||||||
|
return Some(link.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.queue_draw();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the position of the specified node inside the graphview.
|
/// Get the position of the specified node inside the graphview.
|
||||||
///
|
///
|
||||||
/// Returns `None` if the node is not in the graphview.
|
/// Returns `None` if the node is not in the graphview.
|
||||||
|
@ -579,7 +630,7 @@ impl GraphView {
|
||||||
self.queue_draw();
|
self.queue_draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn link_exists(&self, new_link: &NodeLink) -> bool {
|
pub(super) fn link_exists(&self, new_link: &Link) -> bool {
|
||||||
let private = imp::GraphView::from_instance(self);
|
let private = imp::GraphView::from_instance(self);
|
||||||
|
|
||||||
for link in private.links.borrow().values() {
|
for link in private.links.borrow().values() {
|
||||||
|
@ -625,6 +676,13 @@ impl GraphView {
|
||||||
private.current_node_id.get()
|
private.current_node_id.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_current_node_id(&self, node_id: u32) {
|
||||||
|
let private = imp::GraphView::from_instance(self);
|
||||||
|
if node_id > private.current_node_id.get() {
|
||||||
|
private.current_node_id.set(node_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn next_port_id(&self) -> u32 {
|
pub fn next_port_id(&self) -> u32 {
|
||||||
let private = imp::GraphView::from_instance(self);
|
let private = imp::GraphView::from_instance(self);
|
||||||
private
|
private
|
||||||
|
@ -633,6 +691,13 @@ impl GraphView {
|
||||||
private.current_port_id.get()
|
private.current_port_id.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_current_port_id(&self, port_id: u32) {
|
||||||
|
let private = imp::GraphView::from_instance(self);
|
||||||
|
if port_id > private.current_port_id.get() {
|
||||||
|
private.current_port_id.set(port_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn next_link_id(&self) -> u32 {
|
fn next_link_id(&self) -> u32 {
|
||||||
let private = imp::GraphView::from_instance(self);
|
let private = imp::GraphView::from_instance(self);
|
||||||
private
|
private
|
||||||
|
@ -641,7 +706,15 @@ impl GraphView {
|
||||||
private.current_link_id.get()
|
private.current_link_id.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_current_link_id(&self, link_id: u32) {
|
||||||
|
let private = imp::GraphView::from_instance(self);
|
||||||
|
if link_id > private.current_link_id.get() {
|
||||||
|
private.current_link_id.set(link_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_selected_port(&self, port: Option<&Port>) {
|
fn set_selected_port(&self, port: Option<&Port>) {
|
||||||
|
self.unselect_all();
|
||||||
let private = imp::GraphView::from_instance(self);
|
let private = imp::GraphView::from_instance(self);
|
||||||
*private.port_selected.borrow_mut() = port.cloned();
|
*private.port_selected.borrow_mut() = port.cloned();
|
||||||
}
|
}
|
||||||
|
@ -689,6 +762,36 @@ impl GraphView {
|
||||||
}
|
}
|
||||||
description
|
description
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn unselect_all(&self) {
|
||||||
|
self.unselect_nodes();
|
||||||
|
self.unselect_links();
|
||||||
|
self.queue_draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_selected(&self) {
|
||||||
|
let private = imp::GraphView::from_instance(self);
|
||||||
|
let mut link_id = None;
|
||||||
|
let mut node_id = None;
|
||||||
|
for link in private.links.borrow_mut().values() {
|
||||||
|
if link.selected() {
|
||||||
|
link_id = Some(link.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for node in private.nodes.borrow_mut().values() {
|
||||||
|
if node.selected() {
|
||||||
|
node_id = Some(node.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(id) = link_id {
|
||||||
|
self.remove_link(id);
|
||||||
|
}
|
||||||
|
if let Some(id) = node_id {
|
||||||
|
self.remove_node(id);
|
||||||
|
}
|
||||||
|
self.queue_draw();
|
||||||
|
}
|
||||||
|
|
||||||
//TO BE MOVED
|
//TO BE MOVED
|
||||||
pub fn render_gst(&self) -> String {
|
pub fn render_gst(&self) -> String {
|
||||||
let nodes = self.all_nodes(NodeType::Source);
|
let nodes = self.all_nodes(NodeType::Source);
|
||||||
|
@ -761,7 +864,7 @@ impl GraphView {
|
||||||
|
|
||||||
let mut current_node: Option<Node> = None;
|
let mut current_node: Option<Node> = None;
|
||||||
let mut current_port: Option<Port> = None;
|
let mut current_port: Option<Port> = None;
|
||||||
let mut current_link: Option<NodeLink> = None;
|
let mut current_link: Option<Link> = None;
|
||||||
for e in parser {
|
for e in parser {
|
||||||
match e {
|
match e {
|
||||||
Ok(XMLREvent::StartElement {
|
Ok(XMLREvent::StartElement {
|
||||||
|
@ -841,14 +944,15 @@ impl GraphView {
|
||||||
let active: &String = attrs
|
let active: &String = attrs
|
||||||
.get::<String>(&String::from("active"))
|
.get::<String>(&String::from("active"))
|
||||||
.expect("Unable to find link state");
|
.expect("Unable to find link state");
|
||||||
current_link = Some(NodeLink {
|
current_link = Some(Link::new(
|
||||||
id: id.parse::<u32>().unwrap(),
|
id.parse::<u32>().unwrap(),
|
||||||
node_from: node_from.parse::<u32>().unwrap(),
|
node_from.parse::<u32>().unwrap(),
|
||||||
node_to: node_to.parse::<u32>().unwrap(),
|
node_to.parse::<u32>().unwrap(),
|
||||||
port_from: port_from.parse::<u32>().unwrap(),
|
port_from.parse::<u32>().unwrap(),
|
||||||
port_to: port_to.parse::<u32>().unwrap(),
|
port_to.parse::<u32>().unwrap(),
|
||||||
active: active.parse::<bool>().unwrap(),
|
active.parse::<bool>().unwrap(),
|
||||||
});
|
false,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
_ => println!("name unknown: {}", name),
|
_ => println!("name unknown: {}", name),
|
||||||
}
|
}
|
||||||
|
@ -857,12 +961,13 @@ impl GraphView {
|
||||||
println!("closing {}", name);
|
println!("closing {}", name);
|
||||||
match name.to_string().as_str() {
|
match name.to_string().as_str() {
|
||||||
"Graph" => {
|
"Graph" => {
|
||||||
println!("Graph ended");
|
println!("Graph ended with success");
|
||||||
}
|
}
|
||||||
"Node" => {
|
"Node" => {
|
||||||
if let Some(node) = current_node {
|
if let Some(node) = current_node {
|
||||||
let id = node.id();
|
let id = node.id();
|
||||||
self.add_node(id, node);
|
self.add_node(id, node);
|
||||||
|
self.update_current_node_id(id);
|
||||||
}
|
}
|
||||||
current_node = None;
|
current_node = None;
|
||||||
}
|
}
|
||||||
|
@ -870,17 +975,21 @@ impl GraphView {
|
||||||
"Port" => {
|
"Port" => {
|
||||||
if let Some(port) = current_port {
|
if let Some(port) = current_port {
|
||||||
let node = current_node.clone();
|
let node = current_node.clone();
|
||||||
|
let id = port.id();
|
||||||
node.expect("No current node, error...").add_port(
|
node.expect("No current node, error...").add_port(
|
||||||
port.id(),
|
id,
|
||||||
&port.name(),
|
&port.name(),
|
||||||
port.direction(),
|
port.direction(),
|
||||||
);
|
);
|
||||||
|
self.update_current_port_id(id);
|
||||||
}
|
}
|
||||||
current_port = None;
|
current_port = None;
|
||||||
}
|
}
|
||||||
"Link" => {
|
"Link" => {
|
||||||
if let Some(link) = current_link {
|
if let Some(link) = current_link {
|
||||||
|
let id = link.id;
|
||||||
self.add_link(link);
|
self.add_link(link);
|
||||||
|
self.update_current_link_id(id);
|
||||||
}
|
}
|
||||||
current_link = None;
|
current_link = None;
|
||||||
}
|
}
|
||||||
|
|
68
src/graphmanager/link.rs
Normal file
68
src/graphmanager/link.rs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
// link.rs
|
||||||
|
//
|
||||||
|
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
|
||||||
|
// Copyright 2021 Stéphane Cerveau <scerveau@collabora.com>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Link {
|
||||||
|
pub id: u32,
|
||||||
|
pub node_from: u32,
|
||||||
|
pub node_to: u32,
|
||||||
|
pub port_from: u32,
|
||||||
|
pub port_to: u32,
|
||||||
|
pub active: bool,
|
||||||
|
pub selected: Cell<bool>,
|
||||||
|
pub thickness: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Link {
|
||||||
|
pub fn new(
|
||||||
|
id: u32,
|
||||||
|
node_from: u32,
|
||||||
|
node_to: u32,
|
||||||
|
port_from: u32,
|
||||||
|
port_to: u32,
|
||||||
|
active: bool,
|
||||||
|
selected: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
node_from,
|
||||||
|
node_to,
|
||||||
|
port_from,
|
||||||
|
port_to,
|
||||||
|
active,
|
||||||
|
selected: Cell::new(selected),
|
||||||
|
thickness: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_selected(&self) {
|
||||||
|
self.set_selected(!self.selected.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_selected(&self, selected: bool) {
|
||||||
|
self.selected.set(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> bool {
|
||||||
|
self.selected.get()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,10 @@
|
||||||
mod graphview;
|
mod graphview;
|
||||||
|
mod link;
|
||||||
mod node;
|
mod node;
|
||||||
mod port;
|
mod port;
|
||||||
|
|
||||||
pub use graphview::GraphView;
|
pub use graphview::GraphView;
|
||||||
|
pub use link::Link;
|
||||||
pub use node::Node;
|
pub use node::Node;
|
||||||
pub use node::NodeType;
|
pub use node::NodeType;
|
||||||
pub use port::Port;
|
pub use port::Port;
|
||||||
|
|
|
@ -58,16 +58,21 @@ impl NodeType {
|
||||||
|
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use gtk::Orientation;
|
||||||
use once_cell::unsync::OnceCell;
|
use once_cell::unsync::OnceCell;
|
||||||
pub struct Node {
|
pub struct Node {
|
||||||
pub(super) grid: gtk::Grid,
|
pub(super) layoutbox: gtk::Box,
|
||||||
pub(super) label: gtk::Label,
|
pub(super) inputs: gtk::Box,
|
||||||
|
pub(super) outputs: gtk::Box,
|
||||||
|
pub(super) name: gtk::Label,
|
||||||
|
pub(super) description: gtk::Label,
|
||||||
pub(super) id: OnceCell<u32>,
|
pub(super) id: OnceCell<u32>,
|
||||||
pub(super) node_type: OnceCell<NodeType>,
|
pub(super) node_type: OnceCell<NodeType>,
|
||||||
pub(super) ports: RefCell<HashMap<u32, Port>>,
|
pub(super) ports: RefCell<HashMap<u32, Port>>,
|
||||||
pub(super) num_ports_in: Cell<i32>,
|
pub(super) num_ports_in: Cell<i32>,
|
||||||
pub(super) num_ports_out: Cell<i32>,
|
pub(super) num_ports_out: Cell<i32>,
|
||||||
pub(super) properties: RefCell<HashMap<String, String>>,
|
pub(super) properties: RefCell<HashMap<String, String>>,
|
||||||
|
pub(super) selected: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[glib::object_subclass]
|
#[glib::object_subclass]
|
||||||
|
@ -82,23 +87,62 @@ mod imp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
let grid = gtk::Grid::new();
|
let layoutbox = gtk::Box::new(Orientation::Vertical, 6);
|
||||||
let label = gtk::Label::new(None);
|
let name_desc = gtk::Box::new(Orientation::Vertical, 6);
|
||||||
|
layoutbox.append(&name_desc);
|
||||||
|
let ports = gtk::Box::builder()
|
||||||
|
.orientation(Orientation::Horizontal)
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.spacing(10)
|
||||||
|
.margin_bottom(10)
|
||||||
|
.margin_top(10)
|
||||||
|
.build();
|
||||||
|
|
||||||
grid.attach(&label, 0, 0, 2, 1);
|
layoutbox.append(&ports);
|
||||||
|
let inputs = gtk::Box::builder()
|
||||||
|
.orientation(Orientation::Vertical)
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.spacing(10)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Display a grab cursor when the mouse is over the label so the user knows the node can be dragged.
|
ports.append(&inputs);
|
||||||
label.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
|
let center = gtk::Box::builder()
|
||||||
|
.orientation(Orientation::Vertical)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.hexpand(true)
|
||||||
|
.margin_start(20)
|
||||||
|
.margin_end(20)
|
||||||
|
.build();
|
||||||
|
ports.append(¢er);
|
||||||
|
let outputs = gtk::Box::builder()
|
||||||
|
.orientation(Orientation::Vertical)
|
||||||
|
.halign(gtk::Align::End)
|
||||||
|
.spacing(10)
|
||||||
|
.build();
|
||||||
|
ports.append(&outputs);
|
||||||
|
|
||||||
|
let name = gtk::Label::new(None);
|
||||||
|
name_desc.append(&name);
|
||||||
|
|
||||||
|
let description = gtk::Label::new(None);
|
||||||
|
name_desc.append(&description);
|
||||||
|
|
||||||
|
// Display a grab cursor when the mouse is over the name so the user knows the node can be dragged.
|
||||||
|
name.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
grid,
|
layoutbox,
|
||||||
label,
|
inputs,
|
||||||
|
outputs,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
id: OnceCell::new(),
|
id: OnceCell::new(),
|
||||||
node_type: OnceCell::new(),
|
node_type: OnceCell::new(),
|
||||||
ports: RefCell::new(HashMap::new()),
|
ports: RefCell::new(HashMap::new()),
|
||||||
num_ports_in: Cell::new(0),
|
num_ports_in: Cell::new(0),
|
||||||
num_ports_out: Cell::new(0),
|
num_ports_out: Cell::new(0),
|
||||||
properties: RefCell::new(HashMap::new()),
|
properties: RefCell::new(HashMap::new()),
|
||||||
|
selected: Cell::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -106,11 +150,11 @@ mod imp {
|
||||||
impl ObjectImpl for Node {
|
impl ObjectImpl for Node {
|
||||||
fn constructed(&self, obj: &Self::Type) {
|
fn constructed(&self, obj: &Self::Type) {
|
||||||
self.parent_constructed(obj);
|
self.parent_constructed(obj);
|
||||||
self.grid.set_parent(obj);
|
self.layoutbox.set_parent(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispose(&self, _obj: &Self::Type) {
|
fn dispose(&self, _obj: &Self::Type) {
|
||||||
self.grid.unparent();
|
self.layoutbox.unparent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,6 +172,7 @@ impl Node {
|
||||||
let private = imp::Node::from_instance(&res);
|
let private = imp::Node::from_instance(&res);
|
||||||
private.id.set(id).expect("Node id already set");
|
private.id.set(id).expect("Node id already set");
|
||||||
res.set_name(name);
|
res.set_name(name);
|
||||||
|
res.add_css_class("node");
|
||||||
private
|
private
|
||||||
.node_type
|
.node_type
|
||||||
.set(node_type)
|
.set(node_type)
|
||||||
|
@ -137,24 +182,36 @@ impl Node {
|
||||||
|
|
||||||
fn set_name(&self, name: &str) {
|
fn set_name(&self, name: &str) {
|
||||||
let self_ = imp::Node::from_instance(self);
|
let self_ = imp::Node::from_instance(self);
|
||||||
self_.label.set_text(name);
|
self_.name.set_text(name);
|
||||||
println!("{}", name);
|
println!("{}", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_description(&self, description: &str) {
|
||||||
|
let self_ = imp::Node::from_instance(self);
|
||||||
|
self_.description.set_text(description);
|
||||||
|
println!("{}", description);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_description(&self) {
|
||||||
|
let self_ = imp::Node::from_instance(self);
|
||||||
|
let mut description = String::from("");
|
||||||
|
for (name, value) in self_.properties.borrow().iter() {
|
||||||
|
description.push_str(&format!("{}:{}", name, value));
|
||||||
|
description.push('\n');
|
||||||
|
}
|
||||||
|
self.set_description(&description);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_port(&mut self, id: u32, name: &str, direction: PortDirection) {
|
pub fn add_port(&mut self, id: u32, name: &str, direction: PortDirection) {
|
||||||
let private = imp::Node::from_instance(self);
|
let private = imp::Node::from_instance(self);
|
||||||
let port = Port::new(id, name, direction);
|
let port = Port::new(id, name, direction);
|
||||||
match port.direction() {
|
match port.direction() {
|
||||||
PortDirection::Input => {
|
PortDirection::Input => {
|
||||||
private
|
private.inputs.append(&port);
|
||||||
.grid
|
|
||||||
.attach(&port, 0, private.num_ports_in.get() + 1, 1, 1);
|
|
||||||
private.num_ports_in.set(private.num_ports_in.get() + 1);
|
private.num_ports_in.set(private.num_ports_in.get() + 1);
|
||||||
}
|
}
|
||||||
PortDirection::Output => {
|
PortDirection::Output => {
|
||||||
private
|
private.outputs.append(&port);
|
||||||
.grid
|
|
||||||
.attach(&port, 1, private.num_ports_out.get() + 1, 1, 1);
|
|
||||||
private.num_ports_out.set(private.num_ports_out.get() + 1);
|
private.num_ports_out.set(private.num_ports_out.get() + 1);
|
||||||
}
|
}
|
||||||
_ => panic!("Port without direction"),
|
_ => panic!("Port without direction"),
|
||||||
|
@ -202,12 +259,12 @@ impl Node {
|
||||||
|
|
||||||
pub fn name(&self) -> String {
|
pub fn name(&self) -> String {
|
||||||
let private = imp::Node::from_instance(self);
|
let private = imp::Node::from_instance(self);
|
||||||
private.label.text().to_string()
|
private.name.text().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unique_name(&self) -> String {
|
pub fn unique_name(&self) -> String {
|
||||||
let private = imp::Node::from_instance(self);
|
let private = imp::Node::from_instance(self);
|
||||||
let mut unique_name = private.label.text().to_string();
|
let mut unique_name = private.name.text().to_string();
|
||||||
unique_name.push_str(&self.id().to_string());
|
unique_name.push_str(&self.id().to_string());
|
||||||
unique_name
|
unique_name
|
||||||
}
|
}
|
||||||
|
@ -221,6 +278,7 @@ impl Node {
|
||||||
let private = imp::Node::from_instance(self);
|
let private = imp::Node::from_instance(self);
|
||||||
println!("{} {} updated", name, value);
|
println!("{} {} updated", name, value);
|
||||||
private.properties.borrow_mut().insert(name, value);
|
private.properties.borrow_mut().insert(name, value);
|
||||||
|
self.update_description();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_node_properties(&self, new_properties: &HashMap<String, String>) {
|
pub fn update_node_properties(&self, new_properties: &HashMap<String, String>) {
|
||||||
|
@ -233,4 +291,30 @@ impl Node {
|
||||||
let private = imp::Node::from_instance(self);
|
let private = imp::Node::from_instance(self);
|
||||||
private.properties.borrow()
|
private.properties.borrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn toggle_selected(&self) {
|
||||||
|
self.set_selected(!self.selected());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_selected(&self, selected: bool) {
|
||||||
|
let private = imp::Node::from_instance(self);
|
||||||
|
private.selected.set(selected);
|
||||||
|
if selected {
|
||||||
|
self.add_css_class("node-selected");
|
||||||
|
} else {
|
||||||
|
self.remove_css_class("node-selected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> bool {
|
||||||
|
let private = imp::Node::from_instance(self);
|
||||||
|
private.selected.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unselect_all_ports(&self) {
|
||||||
|
let private = imp::Node::from_instance(self);
|
||||||
|
for port in private.ports.borrow_mut().values() {
|
||||||
|
port.set_selected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ use gtk::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
subclass::prelude::*,
|
subclass::prelude::*,
|
||||||
};
|
};
|
||||||
|
use std::cell::Cell;
|
||||||
use std::{borrow::Borrow, fmt};
|
use std::{borrow::Borrow, fmt};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Copy)]
|
#[derive(Debug, Clone, PartialEq, Copy)]
|
||||||
|
@ -61,6 +62,7 @@ mod imp {
|
||||||
pub(super) label: OnceCell<gtk::Label>,
|
pub(super) label: OnceCell<gtk::Label>,
|
||||||
pub(super) id: OnceCell<u32>,
|
pub(super) id: OnceCell<u32>,
|
||||||
pub(super) direction: OnceCell<PortDirection>,
|
pub(super) direction: OnceCell<PortDirection>,
|
||||||
|
pub(super) selected: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[glib::object_subclass]
|
#[glib::object_subclass]
|
||||||
|
@ -110,26 +112,29 @@ glib::wrapper! {
|
||||||
impl Port {
|
impl Port {
|
||||||
pub fn new(id: u32, name: &str, direction: PortDirection) -> Self {
|
pub fn new(id: u32, name: &str, direction: PortDirection) -> Self {
|
||||||
// Create the widget and initialize needed fields
|
// Create the widget and initialize needed fields
|
||||||
let res: Self = glib::Object::new(&[]).expect("Failed to create Port");
|
let port: Self = glib::Object::new(&[]).expect("Failed to create Port");
|
||||||
|
port.add_css_class("port");
|
||||||
let private = imp::Port::from_instance(&res);
|
let private = imp::Port::from_instance(&port);
|
||||||
private.id.set(id).expect("Port id already set");
|
private.id.set(id).expect("Port id already set");
|
||||||
|
private.selected.set(false);
|
||||||
private
|
private
|
||||||
.direction
|
.direction
|
||||||
.set(direction)
|
.set(direction)
|
||||||
.expect("Port direction already set");
|
.expect("Port direction already set");
|
||||||
|
if direction == PortDirection::Input {
|
||||||
|
port.add_css_class("port-in");
|
||||||
|
} else {
|
||||||
|
port.add_css_class("port-out");
|
||||||
|
}
|
||||||
|
|
||||||
let label = gtk::Label::new(Some(name));
|
let label = gtk::Label::new(Some(name));
|
||||||
label.set_parent(&res);
|
label.set_parent(&port);
|
||||||
private
|
private
|
||||||
.label
|
.label
|
||||||
.set(label)
|
.set(label)
|
||||||
.expect("Port label was already set");
|
.expect("Port label was already set");
|
||||||
|
|
||||||
// Display a grab cursor when the mouse is over the port so the user knows it can be dragged to another port.
|
port
|
||||||
res.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
|
|
||||||
|
|
||||||
res
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> u32 {
|
pub fn id(&self) -> u32 {
|
||||||
|
@ -147,4 +152,23 @@ impl Port {
|
||||||
let label = private.label.borrow().get().unwrap();
|
let label = private.label.borrow().get().unwrap();
|
||||||
label.text().to_string()
|
label.text().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn toggle_selected(&self) {
|
||||||
|
self.set_selected(!self.selected());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_selected(&self, selected: bool) {
|
||||||
|
let private = imp::Port::from_instance(self);
|
||||||
|
private.selected.set(selected);
|
||||||
|
if selected {
|
||||||
|
self.add_css_class("port-selected");
|
||||||
|
} else {
|
||||||
|
self.remove_css_class("port-selected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> bool {
|
||||||
|
let private = imp::Port::from_instance(self);
|
||||||
|
private.selected.get()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue