moved coretk under daemon/core/gui
This commit is contained in:
parent
f13c62a1c9
commit
0b5c94778c
118 changed files with 505 additions and 432 deletions
0
daemon/core/gui/graph/__init__.py
Normal file
0
daemon/core/gui/graph/__init__.py
Normal file
181
daemon/core/gui/graph/edges.py
Normal file
181
daemon/core/gui/graph/edges.py
Normal file
|
@ -0,0 +1,181 @@
|
|||
import logging
|
||||
import tkinter as tk
|
||||
from tkinter.font import Font
|
||||
|
||||
from core.gui import themes
|
||||
from core.gui.dialogs.linkconfig import LinkConfiguration
|
||||
from core.gui.graph import tags
|
||||
from core.gui.nodeutils import NodeUtils
|
||||
|
||||
TEXT_DISTANCE = 0.30
|
||||
|
||||
|
||||
class CanvasWirelessEdge:
|
||||
def __init__(self, token, position, src, dst, canvas):
|
||||
self.token = token
|
||||
self.src = src
|
||||
self.dst = dst
|
||||
self.canvas = canvas
|
||||
self.id = self.canvas.create_line(
|
||||
*position, tags=tags.WIRELESS_EDGE, width=1.5, fill="#009933"
|
||||
)
|
||||
|
||||
def delete(self):
|
||||
self.canvas.delete(self.id)
|
||||
|
||||
|
||||
class CanvasEdge:
|
||||
"""
|
||||
Canvas edge class
|
||||
"""
|
||||
|
||||
width = 3
|
||||
|
||||
def __init__(self, x1, y1, x2, y2, src, canvas):
|
||||
"""
|
||||
Create an instance of canvas edge object
|
||||
:param int x1: source x-coord
|
||||
:param int y1: source y-coord
|
||||
:param int x2: destination x-coord
|
||||
:param int y2: destination y-coord
|
||||
:param int src: source id
|
||||
:param coretk.graph.graph.GraphCanvas canvas: canvas object
|
||||
"""
|
||||
self.src = src
|
||||
self.dst = None
|
||||
self.src_interface = None
|
||||
self.dst_interface = None
|
||||
self.canvas = canvas
|
||||
self.id = self.canvas.create_line(
|
||||
x1, y1, x2, y2, tags=tags.EDGE, width=self.width, fill="#ff0000"
|
||||
)
|
||||
self.text_src = None
|
||||
self.text_dst = None
|
||||
self.token = None
|
||||
self.font = Font(size=8)
|
||||
self.link = None
|
||||
self.asymmetric_link = None
|
||||
self.throughput = None
|
||||
self.set_binding()
|
||||
|
||||
def set_binding(self):
|
||||
self.canvas.tag_bind(self.id, "<ButtonRelease-3>", self.create_context)
|
||||
|
||||
def set_link(self, link):
|
||||
self.link = link
|
||||
self.draw_labels()
|
||||
|
||||
def get_coordinates(self):
|
||||
x1, y1, x2, y2 = self.canvas.coords(self.id)
|
||||
v1 = x2 - x1
|
||||
v2 = y2 - y1
|
||||
ux = TEXT_DISTANCE * v1
|
||||
uy = TEXT_DISTANCE * v2
|
||||
x1 = x1 + ux
|
||||
y1 = y1 + uy
|
||||
x2 = x2 - ux
|
||||
y2 = y2 - uy
|
||||
return x1, y1, x2, y2
|
||||
|
||||
def draw_labels(self):
|
||||
x1, y1, x2, y2 = self.get_coordinates()
|
||||
label_one = None
|
||||
if self.link.HasField("interface_one"):
|
||||
label_one = (
|
||||
f"{self.link.interface_one.ip4}/{self.link.interface_one.ip4mask}\n"
|
||||
f"{self.link.interface_one.ip6}/{self.link.interface_one.ip6mask}\n"
|
||||
)
|
||||
label_two = None
|
||||
if self.link.HasField("interface_two"):
|
||||
label_two = (
|
||||
f"{self.link.interface_two.ip4}/{self.link.interface_two.ip4mask}\n"
|
||||
f"{self.link.interface_two.ip6}/{self.link.interface_two.ip6mask}\n"
|
||||
)
|
||||
self.text_src = self.canvas.create_text(
|
||||
x1,
|
||||
y1,
|
||||
text=label_one,
|
||||
justify=tk.CENTER,
|
||||
font=self.font,
|
||||
tags=tags.LINK_INFO,
|
||||
)
|
||||
self.text_dst = self.canvas.create_text(
|
||||
x2,
|
||||
y2,
|
||||
text=label_two,
|
||||
justify=tk.CENTER,
|
||||
font=self.font,
|
||||
tags=tags.LINK_INFO,
|
||||
)
|
||||
|
||||
def update_labels(self):
|
||||
"""
|
||||
Move edge labels based on current position.
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
x1, y1, x2, y2 = self.get_coordinates()
|
||||
self.canvas.coords(self.text_src, x1, y1)
|
||||
self.canvas.coords(self.text_dst, x2, y2)
|
||||
|
||||
def complete(self, dst):
|
||||
self.dst = dst
|
||||
self.token = tuple(sorted((self.src, self.dst)))
|
||||
x, y = self.canvas.coords(self.dst)
|
||||
x1, y1, _, _ = self.canvas.coords(self.id)
|
||||
self.canvas.coords(self.id, x1, y1, x, y)
|
||||
self.check_wireless()
|
||||
self.canvas.tag_raise(self.src)
|
||||
self.canvas.tag_raise(self.dst)
|
||||
|
||||
def check_wireless(self):
|
||||
src_node = self.canvas.nodes[self.src]
|
||||
dst_node = self.canvas.nodes[self.dst]
|
||||
src_node_type = src_node.core_node.type
|
||||
dst_node_type = dst_node.core_node.type
|
||||
is_src_wireless = NodeUtils.is_wireless_node(src_node_type)
|
||||
is_dst_wireless = NodeUtils.is_wireless_node(dst_node_type)
|
||||
if is_src_wireless or is_dst_wireless:
|
||||
self.canvas.itemconfig(self.id, state=tk.HIDDEN)
|
||||
self._check_antenna()
|
||||
|
||||
def _check_antenna(self):
|
||||
src_node = self.canvas.nodes[self.src]
|
||||
dst_node = self.canvas.nodes[self.dst]
|
||||
src_node_type = src_node.core_node.type
|
||||
dst_node_type = dst_node.core_node.type
|
||||
is_src_wireless = NodeUtils.is_wireless_node(src_node_type)
|
||||
is_dst_wireless = NodeUtils.is_wireless_node(dst_node_type)
|
||||
if is_src_wireless or is_dst_wireless:
|
||||
if is_src_wireless and not is_dst_wireless:
|
||||
dst_node.add_antenna()
|
||||
elif not is_src_wireless and is_dst_wireless:
|
||||
src_node.add_antenna()
|
||||
# TODO: remove this? dont allow linking wireless nodes?
|
||||
else:
|
||||
src_node.add_antenna()
|
||||
|
||||
def delete(self):
|
||||
self.canvas.delete(self.id)
|
||||
if self.link:
|
||||
self.canvas.delete(self.text_src)
|
||||
self.canvas.delete(self.text_dst)
|
||||
|
||||
def create_context(self, event):
|
||||
logging.debug("create link context")
|
||||
context = tk.Menu(self.canvas)
|
||||
themes.style_menu(context)
|
||||
context.add_command(label="Configure", command=self.configure)
|
||||
context.add_command(label="Delete")
|
||||
context.add_command(label="Split")
|
||||
context.add_command(label="Merge")
|
||||
if self.canvas.app.core.is_runtime():
|
||||
context.entryconfigure(1, state="disabled")
|
||||
context.entryconfigure(2, state="disabled")
|
||||
context.entryconfigure(3, state="disabled")
|
||||
context.post(event.x_root, event.y_root)
|
||||
|
||||
def configure(self):
|
||||
logging.debug("link configuration")
|
||||
dialog = LinkConfiguration(self.canvas, self.canvas.app, self)
|
||||
dialog.show()
|
18
daemon/core/gui/graph/enums.py
Normal file
18
daemon/core/gui/graph/enums.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
import enum
|
||||
|
||||
|
||||
class GraphMode(enum.Enum):
|
||||
SELECT = 0
|
||||
EDGE = 1
|
||||
PICKNODE = 2
|
||||
NODE = 3
|
||||
ANNOTATION = 4
|
||||
OTHER = 5
|
||||
|
||||
|
||||
class ScaleOption(enum.Enum):
|
||||
NONE = 0
|
||||
UPPER_LEFT = 1
|
||||
CENTERED = 2
|
||||
SCALED = 3
|
||||
TILED = 4
|
839
daemon/core/gui/graph/graph.py
Normal file
839
daemon/core/gui/graph/graph.py
Normal file
|
@ -0,0 +1,839 @@
|
|||
import logging
|
||||
import tkinter as tk
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from core.api.grpc import core_pb2
|
||||
from core.gui import nodeutils
|
||||
from core.gui.dialogs.shapemod import ShapeDialog
|
||||
from core.gui.graph import tags
|
||||
from core.gui.graph.edges import CanvasEdge, CanvasWirelessEdge
|
||||
from core.gui.graph.enums import GraphMode, ScaleOption
|
||||
from core.gui.graph.linkinfo import Throughput
|
||||
from core.gui.graph.node import CanvasNode
|
||||
from core.gui.graph.shape import Shape
|
||||
from core.gui.graph.shapeutils import ShapeType, is_draw_shape, is_marker
|
||||
from core.gui.images import Images
|
||||
from core.gui.nodeutils import NodeUtils
|
||||
|
||||
ZOOM_IN = 1.1
|
||||
ZOOM_OUT = 0.9
|
||||
|
||||
|
||||
class CanvasGraph(tk.Canvas):
|
||||
def __init__(self, master, core, width, height):
|
||||
super().__init__(master, highlightthickness=0, background="#cccccc")
|
||||
self.app = master
|
||||
self.core = core
|
||||
self.mode = GraphMode.SELECT
|
||||
self.annotation_type = None
|
||||
self.selection = {}
|
||||
self.select_box = None
|
||||
self.selected = None
|
||||
self.node_draw = None
|
||||
self.context = None
|
||||
self.nodes = {}
|
||||
self.edges = {}
|
||||
self.shapes = {}
|
||||
self.wireless_edges = {}
|
||||
self.drawing_edge = None
|
||||
self.grid = None
|
||||
self.throughput_draw = Throughput(self, core)
|
||||
self.shape_drawing = False
|
||||
self.default_dimensions = (width, height)
|
||||
self.current_dimensions = self.default_dimensions
|
||||
self.ratio = 1.0
|
||||
self.offset = (0, 0)
|
||||
self.cursor = (0, 0)
|
||||
self.marker_tool = None
|
||||
|
||||
# background related
|
||||
self.wallpaper_id = None
|
||||
self.wallpaper = None
|
||||
self.wallpaper_drawn = None
|
||||
self.wallpaper_file = ""
|
||||
self.scale_option = tk.IntVar(value=1)
|
||||
self.show_grid = tk.BooleanVar(value=True)
|
||||
self.adjust_to_dim = tk.BooleanVar(value=False)
|
||||
|
||||
# bindings
|
||||
self.setup_bindings()
|
||||
|
||||
# draw base canvas
|
||||
self.draw_canvas()
|
||||
self.draw_grid()
|
||||
|
||||
def draw_canvas(self, dimensions=None):
|
||||
if self.grid is not None:
|
||||
self.delete(self.grid)
|
||||
if not dimensions:
|
||||
dimensions = self.default_dimensions
|
||||
self.current_dimensions = dimensions
|
||||
self.grid = self.create_rectangle(
|
||||
0,
|
||||
0,
|
||||
*dimensions,
|
||||
outline="#000000",
|
||||
fill="#ffffff",
|
||||
width=1,
|
||||
tags="rectangle",
|
||||
)
|
||||
self.configure(scrollregion=self.bbox(tk.ALL))
|
||||
|
||||
def reset_and_redraw(self, session):
|
||||
"""
|
||||
Reset the private variables CanvasGraph object, redraw nodes given the new grpc
|
||||
client.
|
||||
|
||||
:param core.api.grpc.core_pb2.Session session: session to draw
|
||||
:return: nothing
|
||||
"""
|
||||
# hide context
|
||||
self.hide_context()
|
||||
|
||||
# delete any existing drawn items
|
||||
for tag in tags.COMPONENT_TAGS:
|
||||
self.delete(tag)
|
||||
|
||||
# set the private variables to default value
|
||||
self.mode = GraphMode.SELECT
|
||||
self.annotation_type = None
|
||||
self.node_draw = None
|
||||
self.selected = None
|
||||
self.nodes.clear()
|
||||
self.edges.clear()
|
||||
self.shapes.clear()
|
||||
self.wireless_edges.clear()
|
||||
self.drawing_edge = None
|
||||
self.draw_session(session)
|
||||
|
||||
def setup_bindings(self):
|
||||
"""
|
||||
Bind any mouse events or hot keys to the matching action
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
self.bind("<ButtonPress-1>", self.click_press)
|
||||
self.bind("<ButtonRelease-1>", self.click_release)
|
||||
self.bind("<B1-Motion>", self.click_motion)
|
||||
self.bind("<ButtonRelease-3>", self.click_context)
|
||||
self.bind("<Delete>", self.press_delete)
|
||||
self.bind("<Control-1>", self.ctrl_click)
|
||||
self.bind("<Double-Button-1>", self.double_click)
|
||||
self.bind("<MouseWheel>", self.zoom)
|
||||
self.bind("<Button-4>", lambda e: self.zoom(e, ZOOM_IN))
|
||||
self.bind("<Button-5>", lambda e: self.zoom(e, ZOOM_OUT))
|
||||
self.bind("<ButtonPress-3>", lambda e: self.scan_mark(e.x, e.y))
|
||||
self.bind("<B3-Motion>", lambda e: self.scan_dragto(e.x, e.y, gain=1))
|
||||
|
||||
def hide_context(self):
|
||||
if self.context:
|
||||
self.context.unpost()
|
||||
self.context = None
|
||||
|
||||
def get_actual_coords(self, x, y):
|
||||
actual_x = (x - self.offset[0]) / self.ratio
|
||||
actual_y = (y - self.offset[1]) / self.ratio
|
||||
return actual_x, actual_y
|
||||
|
||||
def get_scaled_coords(self, x, y):
|
||||
scaled_x = (x * self.ratio) + self.offset[0]
|
||||
scaled_y = (y * self.ratio) + self.offset[1]
|
||||
return scaled_x, scaled_y
|
||||
|
||||
def inside_canvas(self, x, y):
|
||||
x1, y1, x2, y2 = self.bbox(self.grid)
|
||||
valid_x = x1 <= x <= x2
|
||||
valid_y = y1 <= y <= y2
|
||||
return valid_x and valid_y
|
||||
|
||||
def valid_position(self, x1, y1, x2, y2):
|
||||
valid_topleft = self.inside_canvas(x1, y1)
|
||||
valid_bottomright = self.inside_canvas(x2, y2)
|
||||
return valid_topleft and valid_bottomright
|
||||
|
||||
def draw_grid(self):
|
||||
"""
|
||||
Create grid.
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
width, height = self.width_and_height()
|
||||
width = int(width)
|
||||
height = int(height)
|
||||
for i in range(0, width, 27):
|
||||
self.create_line(i, 0, i, height, dash=(2, 4), tags=tags.GRIDLINE)
|
||||
for i in range(0, height, 27):
|
||||
self.create_line(0, i, width, i, dash=(2, 4), tags=tags.GRIDLINE)
|
||||
self.tag_lower(tags.GRIDLINE)
|
||||
self.tag_lower(self.grid)
|
||||
|
||||
def add_wireless_edge(self, src, dst):
|
||||
"""
|
||||
add a wireless edge between 2 canvas nodes
|
||||
|
||||
:param CanvasNode src: source node
|
||||
:param CanvasNode dst: destination node
|
||||
:return: nothing
|
||||
"""
|
||||
token = tuple(sorted((src.id, dst.id)))
|
||||
x1, y1 = self.coords(src.id)
|
||||
x2, y2 = self.coords(dst.id)
|
||||
position = (x1, y1, x2, y2)
|
||||
edge = CanvasWirelessEdge(token, position, src.id, dst.id, self)
|
||||
self.wireless_edges[token] = edge
|
||||
src.wireless_edges.add(edge)
|
||||
dst.wireless_edges.add(edge)
|
||||
self.tag_raise(src.id)
|
||||
self.tag_raise(dst.id)
|
||||
|
||||
def delete_wireless_edge(self, src, dst):
|
||||
token = tuple(sorted((src.id, dst.id)))
|
||||
edge = self.wireless_edges.pop(token)
|
||||
edge.delete()
|
||||
src.wireless_edges.remove(edge)
|
||||
dst.wireless_edges.remove(edge)
|
||||
|
||||
def draw_session(self, session):
|
||||
"""
|
||||
Draw existing session.
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
# draw existing nodes
|
||||
for core_node in session.nodes:
|
||||
# peer to peer node is not drawn on the GUI
|
||||
if NodeUtils.is_ignore_node(core_node.type):
|
||||
continue
|
||||
|
||||
# draw nodes on the canvas
|
||||
logging.info("drawing core node: %s", core_node)
|
||||
image = NodeUtils.node_icon(core_node.type, core_node.model)
|
||||
if core_node.icon:
|
||||
try:
|
||||
image = Images.create(core_node.icon, nodeutils.ICON_SIZE)
|
||||
except OSError:
|
||||
logging.error("invalid icon: %s", core_node.icon)
|
||||
|
||||
x = core_node.position.x
|
||||
y = core_node.position.y
|
||||
node = CanvasNode(self.master, x, y, core_node, image)
|
||||
self.nodes[node.id] = node
|
||||
self.core.canvas_nodes[core_node.id] = node
|
||||
|
||||
# draw existing links
|
||||
for link in session.links:
|
||||
logging.info("drawing link: %s", link)
|
||||
canvas_node_one = self.core.canvas_nodes[link.node_one_id]
|
||||
node_one = canvas_node_one.core_node
|
||||
canvas_node_two = self.core.canvas_nodes[link.node_two_id]
|
||||
node_two = canvas_node_two.core_node
|
||||
token = tuple(sorted((canvas_node_one.id, canvas_node_two.id)))
|
||||
|
||||
if link.type == core_pb2.LinkType.WIRELESS:
|
||||
self.add_wireless_edge(canvas_node_one, canvas_node_two)
|
||||
else:
|
||||
if token not in self.edges:
|
||||
edge = CanvasEdge(
|
||||
node_one.position.x,
|
||||
node_one.position.y,
|
||||
node_two.position.x,
|
||||
node_two.position.y,
|
||||
canvas_node_one.id,
|
||||
self,
|
||||
)
|
||||
edge.token = token
|
||||
edge.dst = canvas_node_two.id
|
||||
edge.set_link(link)
|
||||
edge.check_wireless()
|
||||
canvas_node_one.edges.add(edge)
|
||||
canvas_node_two.edges.add(edge)
|
||||
self.edges[edge.token] = edge
|
||||
self.core.links[edge.token] = edge
|
||||
if link.HasField("interface_one"):
|
||||
canvas_node_one.interfaces.append(link.interface_one)
|
||||
if link.HasField("interface_two"):
|
||||
canvas_node_two.interfaces.append(link.interface_two)
|
||||
elif link.options.unidirectional:
|
||||
edge = self.edges[token]
|
||||
edge.asymmetric_link = link
|
||||
else:
|
||||
logging.error("duplicate link received: %s", link)
|
||||
|
||||
# raise the nodes so they on top of the links
|
||||
self.tag_raise(tags.NODE)
|
||||
|
||||
def canvas_xy(self, event):
|
||||
"""
|
||||
Convert window coordinate to canvas coordinate
|
||||
|
||||
:param event:
|
||||
:rtype: (int, int)
|
||||
:return: x, y canvas coordinate
|
||||
"""
|
||||
x = self.canvasx(event.x)
|
||||
y = self.canvasy(event.y)
|
||||
return x, y
|
||||
|
||||
def get_selected(self, event):
|
||||
"""
|
||||
Retrieve the item id that is on the mouse position
|
||||
|
||||
:param event: mouse event
|
||||
:rtype: int
|
||||
:return: the item that the mouse point to
|
||||
"""
|
||||
x, y = self.canvas_xy(event)
|
||||
overlapping = self.find_overlapping(x, y, x, y)
|
||||
selected = None
|
||||
for _id in overlapping:
|
||||
if self.drawing_edge and self.drawing_edge.id == _id:
|
||||
continue
|
||||
|
||||
if _id in self.nodes:
|
||||
selected = _id
|
||||
break
|
||||
|
||||
if _id in self.shapes:
|
||||
selected = _id
|
||||
|
||||
return selected
|
||||
|
||||
def click_release(self, event):
|
||||
"""
|
||||
Draw a node or finish drawing an edge according to the current graph mode
|
||||
|
||||
:param event: mouse event
|
||||
:return: nothing
|
||||
"""
|
||||
logging.debug("click release")
|
||||
x, y = self.canvas_xy(event)
|
||||
if not self.inside_canvas(x, y):
|
||||
return
|
||||
|
||||
if self.context:
|
||||
self.hide_context()
|
||||
else:
|
||||
if self.mode == GraphMode.ANNOTATION:
|
||||
self.focus_set()
|
||||
if self.shape_drawing:
|
||||
shape = self.shapes[self.selected]
|
||||
shape.shape_complete(x, y)
|
||||
self.shape_drawing = False
|
||||
elif self.mode == GraphMode.SELECT:
|
||||
self.focus_set()
|
||||
if self.select_box:
|
||||
x0, y0, x1, y1 = self.coords(self.select_box.id)
|
||||
inside = [
|
||||
x
|
||||
for x in self.find_enclosed(x0, y0, x1, y1)
|
||||
if "node" in self.gettags(x) or "shape" in self.gettags(x)
|
||||
]
|
||||
for i in inside:
|
||||
self.select_object(i, True)
|
||||
self.select_box.disappear()
|
||||
self.select_box = None
|
||||
else:
|
||||
self.focus_set()
|
||||
self.selected = self.get_selected(event)
|
||||
logging.debug(
|
||||
f"click release selected({self.selected}) mode({self.mode})"
|
||||
)
|
||||
if self.mode == GraphMode.EDGE:
|
||||
self.handle_edge_release(event)
|
||||
elif self.mode == GraphMode.NODE:
|
||||
self.add_node(x, y)
|
||||
elif self.mode == GraphMode.PICKNODE:
|
||||
self.mode = GraphMode.NODE
|
||||
self.selected = None
|
||||
|
||||
def handle_edge_release(self, event):
|
||||
edge = self.drawing_edge
|
||||
self.drawing_edge = None
|
||||
|
||||
# not drawing edge return
|
||||
if edge is None:
|
||||
return
|
||||
|
||||
# edge dst must be a node
|
||||
logging.debug(f"current selected: {self.selected}")
|
||||
dst_node = self.nodes.get(self.selected)
|
||||
if not dst_node:
|
||||
edge.delete()
|
||||
return
|
||||
|
||||
# edge dst is same as src, delete edge
|
||||
if edge.src == self.selected:
|
||||
edge.delete()
|
||||
return
|
||||
|
||||
# ignore repeated edges
|
||||
token = tuple(sorted((edge.src, self.selected)))
|
||||
if token in self.edges:
|
||||
edge.delete()
|
||||
return
|
||||
|
||||
# set dst node and snap edge to center
|
||||
edge.complete(self.selected)
|
||||
logging.debug("drawing edge token: %s", edge.token)
|
||||
|
||||
self.edges[edge.token] = edge
|
||||
node_src = self.nodes[edge.src]
|
||||
node_src.edges.add(edge)
|
||||
node_dst = self.nodes[edge.dst]
|
||||
node_dst.edges.add(edge)
|
||||
self.core.create_link(edge, node_src, node_dst)
|
||||
|
||||
def select_object(self, object_id, choose_multiple=False):
|
||||
"""
|
||||
create a bounding box when a node is selected
|
||||
"""
|
||||
if not choose_multiple:
|
||||
self.clear_selection()
|
||||
|
||||
# draw a bounding box if node hasn't been selected yet
|
||||
if object_id not in self.selection:
|
||||
x0, y0, x1, y1 = self.bbox(object_id)
|
||||
selection_id = self.create_rectangle(
|
||||
(x0 - 6, y0 - 6, x1 + 6, y1 + 6),
|
||||
activedash=True,
|
||||
dash="-",
|
||||
tags=tags.SELECTION,
|
||||
)
|
||||
self.selection[object_id] = selection_id
|
||||
else:
|
||||
selection_id = self.selection.pop(object_id)
|
||||
self.delete(selection_id)
|
||||
|
||||
def clear_selection(self):
|
||||
"""
|
||||
Clear current selection boxes.
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
for _id in self.selection.values():
|
||||
self.delete(_id)
|
||||
self.selection.clear()
|
||||
|
||||
def move_selection(self, object_id, x_offset, y_offset):
|
||||
select_id = self.selection.get(object_id)
|
||||
if select_id is not None:
|
||||
self.move(select_id, x_offset, y_offset)
|
||||
|
||||
def delete_selection_objects(self):
|
||||
edges = set()
|
||||
nodes = []
|
||||
for object_id in self.selection:
|
||||
# delete selection box
|
||||
selection_id = self.selection[object_id]
|
||||
self.delete(selection_id)
|
||||
|
||||
# delete node and related edges
|
||||
if object_id in self.nodes:
|
||||
canvas_node = self.nodes.pop(object_id)
|
||||
canvas_node.delete()
|
||||
nodes.append(canvas_node)
|
||||
is_wireless = NodeUtils.is_wireless_node(canvas_node.core_node.type)
|
||||
|
||||
# delete related edges
|
||||
for edge in canvas_node.edges:
|
||||
if edge in edges:
|
||||
continue
|
||||
edges.add(edge)
|
||||
self.throughput_draw.delete(edge)
|
||||
del self.edges[edge.token]
|
||||
edge.delete()
|
||||
|
||||
# update node connected to edge being deleted
|
||||
other_id = edge.src
|
||||
other_interface = edge.src_interface
|
||||
if edge.src == object_id:
|
||||
other_id = edge.dst
|
||||
other_interface = edge.dst_interface
|
||||
other_node = self.nodes[other_id]
|
||||
other_node.edges.remove(edge)
|
||||
try:
|
||||
other_node.interfaces.remove(other_interface)
|
||||
except ValueError:
|
||||
pass
|
||||
if is_wireless:
|
||||
other_node.delete_antenna()
|
||||
|
||||
# delete shape
|
||||
if object_id in self.shapes:
|
||||
shape = self.shapes.pop(object_id)
|
||||
shape.delete()
|
||||
|
||||
self.selection.clear()
|
||||
return nodes
|
||||
|
||||
def zoom(self, event, factor=None):
|
||||
if not factor:
|
||||
factor = ZOOM_IN if event.delta > 0 else ZOOM_OUT
|
||||
event.x, event.y = self.canvasx(event.x), self.canvasy(event.y)
|
||||
self.scale(tk.ALL, event.x, event.y, factor, factor)
|
||||
self.configure(scrollregion=self.bbox(tk.ALL))
|
||||
self.ratio *= float(factor)
|
||||
self.offset = (
|
||||
self.offset[0] * factor + event.x * (1 - factor),
|
||||
self.offset[1] * factor + event.y * (1 - factor),
|
||||
)
|
||||
logging.info("ratio: %s", self.ratio)
|
||||
logging.info("offset: %s", self.offset)
|
||||
self.app.statusbar.zoom.config(text="%s" % (int(self.ratio * 100)) + "%")
|
||||
|
||||
if self.wallpaper:
|
||||
self.redraw_wallpaper()
|
||||
|
||||
def click_press(self, event):
|
||||
"""
|
||||
Start drawing an edge if mouse click is on a node
|
||||
|
||||
:param event: mouse event
|
||||
:return: nothing
|
||||
"""
|
||||
x, y = self.canvas_xy(event)
|
||||
if not self.inside_canvas(x, y):
|
||||
return
|
||||
|
||||
self.cursor = x, y
|
||||
selected = self.get_selected(event)
|
||||
logging.debug("click press(%s): %s", self.cursor, selected)
|
||||
x_check = self.cursor[0] - self.offset[0]
|
||||
y_check = self.cursor[1] - self.offset[1]
|
||||
logging.debug("clock press ofset(%s, %s)", x_check, y_check)
|
||||
is_node = selected in self.nodes
|
||||
if self.mode == GraphMode.EDGE and is_node:
|
||||
x, y = self.coords(selected)
|
||||
self.drawing_edge = CanvasEdge(x, y, x, y, selected, self)
|
||||
|
||||
if self.mode == GraphMode.ANNOTATION:
|
||||
if is_marker(self.annotation_type):
|
||||
r = self.app.toolbar.marker_tool.radius
|
||||
self.create_oval(
|
||||
x - r,
|
||||
y - r,
|
||||
x + r,
|
||||
y + r,
|
||||
fill=self.app.toolbar.marker_tool.color,
|
||||
outline="",
|
||||
tags="marker",
|
||||
)
|
||||
return
|
||||
if selected is None:
|
||||
shape = Shape(self.app, self, self.annotation_type, x, y)
|
||||
self.selected = shape.id
|
||||
self.shape_drawing = True
|
||||
self.shapes[shape.id] = shape
|
||||
|
||||
if selected is not None:
|
||||
if selected not in self.selection:
|
||||
if selected in self.shapes:
|
||||
shape = self.shapes[selected]
|
||||
self.select_object(shape.id)
|
||||
self.selected = selected
|
||||
elif selected in self.nodes:
|
||||
node = self.nodes[selected]
|
||||
self.select_object(node.id)
|
||||
self.selected = selected
|
||||
logging.info(
|
||||
"selected coords: (%s, %s)",
|
||||
node.core_node.position.x,
|
||||
node.core_node.position.y,
|
||||
)
|
||||
else:
|
||||
logging.debug("create selection box")
|
||||
if self.mode == GraphMode.SELECT:
|
||||
shape = Shape(self.app, self, ShapeType.RECTANGLE, x, y)
|
||||
self.select_box = shape
|
||||
self.clear_selection()
|
||||
|
||||
def ctrl_click(self, event):
|
||||
# update cursor location
|
||||
x, y = self.canvas_xy(event)
|
||||
if not self.inside_canvas(x, y):
|
||||
return
|
||||
|
||||
self.cursor = x, y
|
||||
|
||||
# handle multiple selections
|
||||
logging.debug("control left click: %s", event)
|
||||
selected = self.get_selected(event)
|
||||
if (
|
||||
selected not in self.selection
|
||||
and selected in self.shapes
|
||||
or selected in self.nodes
|
||||
):
|
||||
self.select_object(selected, choose_multiple=True)
|
||||
|
||||
def click_motion(self, event):
|
||||
"""
|
||||
Redraw drawing edge according to the current position of the mouse
|
||||
|
||||
:param event: mouse event
|
||||
:return: nothing
|
||||
"""
|
||||
x, y = self.canvas_xy(event)
|
||||
if not self.inside_canvas(x, y):
|
||||
if self.select_box:
|
||||
self.select_box.delete()
|
||||
self.select_box = None
|
||||
if is_draw_shape(self.annotation_type) and self.shape_drawing:
|
||||
shape = self.shapes.pop(self.selected)
|
||||
shape.delete()
|
||||
self.shape_drawing = False
|
||||
return
|
||||
|
||||
x_offset = x - self.cursor[0]
|
||||
y_offset = y - self.cursor[1]
|
||||
self.cursor = x, y
|
||||
|
||||
if self.mode == GraphMode.EDGE and self.drawing_edge is not None:
|
||||
x1, y1, _, _ = self.coords(self.drawing_edge.id)
|
||||
self.coords(self.drawing_edge.id, x1, y1, x, y)
|
||||
if self.mode == GraphMode.ANNOTATION:
|
||||
if is_draw_shape(self.annotation_type) and self.shape_drawing:
|
||||
shape = self.shapes[self.selected]
|
||||
shape.shape_motion(x, y)
|
||||
elif is_marker(self.annotation_type):
|
||||
r = self.app.toolbar.marker_tool.radius
|
||||
self.create_oval(
|
||||
x - r,
|
||||
y - r,
|
||||
x + r,
|
||||
y + r,
|
||||
fill=self.app.toolbar.marker_tool.color,
|
||||
outline="",
|
||||
tags="marker",
|
||||
)
|
||||
return
|
||||
|
||||
if self.mode == GraphMode.EDGE:
|
||||
return
|
||||
|
||||
# move selected objects
|
||||
if self.selection:
|
||||
for selected_id in self.selection:
|
||||
if selected_id in self.shapes:
|
||||
shape = self.shapes[selected_id]
|
||||
shape.motion(x_offset, y_offset)
|
||||
|
||||
if selected_id in self.nodes:
|
||||
node = self.nodes[selected_id]
|
||||
node.motion(x_offset, y_offset, update=self.core.is_runtime())
|
||||
else:
|
||||
if self.select_box and self.mode == GraphMode.SELECT:
|
||||
self.select_box.shape_motion(x, y)
|
||||
|
||||
def click_context(self, event):
|
||||
logging.info("context event: %s", self.context)
|
||||
if not self.context:
|
||||
selected = self.get_selected(event)
|
||||
canvas_node = self.nodes.get(selected)
|
||||
if canvas_node:
|
||||
logging.debug(f"node context: {selected}")
|
||||
self.context = canvas_node.create_context()
|
||||
self.context.post(event.x_root, event.y_root)
|
||||
else:
|
||||
self.hide_context()
|
||||
|
||||
def press_delete(self, event):
|
||||
"""
|
||||
delete selected nodes and any data that relates to it
|
||||
:param event:
|
||||
:return:
|
||||
"""
|
||||
logging.debug("press delete key")
|
||||
nodes = self.delete_selection_objects()
|
||||
self.core.delete_graph_nodes(nodes)
|
||||
|
||||
def double_click(self, event):
|
||||
selected = self.get_selected(event)
|
||||
if selected is not None and selected in self.shapes:
|
||||
shape = self.shapes[selected]
|
||||
dialog = ShapeDialog(self.app, self.app, shape)
|
||||
dialog.show()
|
||||
|
||||
def add_node(self, x, y):
|
||||
if self.selected is None or self.selected in self.shapes:
|
||||
actual_x, actual_y = self.get_actual_coords(x, y)
|
||||
core_node = self.core.create_node(
|
||||
actual_x, actual_y, self.node_draw.node_type, self.node_draw.model
|
||||
)
|
||||
node = CanvasNode(self.master, x, y, core_node, self.node_draw.image)
|
||||
self.core.canvas_nodes[core_node.id] = node
|
||||
self.nodes[node.id] = node
|
||||
return node
|
||||
|
||||
def width_and_height(self):
|
||||
"""
|
||||
retrieve canvas width and height in pixels
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
x0, y0, x1, y1 = self.coords(self.grid)
|
||||
canvas_w = abs(x0 - x1)
|
||||
canvas_h = abs(y0 - y1)
|
||||
return canvas_w, canvas_h
|
||||
|
||||
def get_wallpaper_image(self):
|
||||
width = int(self.wallpaper.width * self.ratio)
|
||||
height = int(self.wallpaper.height * self.ratio)
|
||||
image = self.wallpaper.resize((width, height), Image.ANTIALIAS)
|
||||
return image
|
||||
|
||||
def draw_wallpaper(self, image, x=None, y=None):
|
||||
if x is None and y is None:
|
||||
x1, y1, x2, y2 = self.bbox(self.grid)
|
||||
x = (x1 + x2) / 2
|
||||
y = (y1 + y2) / 2
|
||||
|
||||
self.wallpaper_id = self.create_image((x, y), image=image, tags=tags.WALLPAPER)
|
||||
self.wallpaper_drawn = image
|
||||
|
||||
def wallpaper_upper_left(self):
|
||||
self.delete(self.wallpaper_id)
|
||||
|
||||
# create new scaled image, cropped if needed
|
||||
width, height = self.width_and_height()
|
||||
image = self.get_wallpaper_image()
|
||||
cropx = image.width
|
||||
cropy = image.height
|
||||
if image.width > width:
|
||||
cropx = image.width
|
||||
if image.height > height:
|
||||
cropy = image.height
|
||||
cropped = image.crop((0, 0, cropx, cropy))
|
||||
image = ImageTk.PhotoImage(cropped)
|
||||
|
||||
# draw on canvas
|
||||
x1, y1, _, _ = self.bbox(self.grid)
|
||||
x = (cropx / 2) + x1
|
||||
y = (cropy / 2) + y1
|
||||
self.draw_wallpaper(image, x, y)
|
||||
|
||||
def wallpaper_center(self):
|
||||
"""
|
||||
place the image at the center of canvas
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
self.delete(self.wallpaper_id)
|
||||
|
||||
# dimension of the cropped image
|
||||
width, height = self.width_and_height()
|
||||
image = self.get_wallpaper_image()
|
||||
cropx = 0
|
||||
if image.width > width:
|
||||
cropx = (image.width - width) / 2
|
||||
cropy = 0
|
||||
if image.height > height:
|
||||
cropy = (image.height - height) / 2
|
||||
x1 = 0 + cropx
|
||||
y1 = 0 + cropy
|
||||
x2 = image.width - cropx
|
||||
y2 = image.height - cropy
|
||||
cropped = image.crop((x1, y1, x2, y2))
|
||||
image = ImageTk.PhotoImage(cropped)
|
||||
self.draw_wallpaper(image)
|
||||
|
||||
def wallpaper_scaled(self):
|
||||
"""
|
||||
scale image based on canvas dimension
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
self.delete(self.wallpaper_id)
|
||||
canvas_w, canvas_h = self.width_and_height()
|
||||
image = self.wallpaper.resize((int(canvas_w), int(canvas_h)), Image.ANTIALIAS)
|
||||
image = ImageTk.PhotoImage(image)
|
||||
self.draw_wallpaper(image)
|
||||
|
||||
def resize_to_wallpaper(self):
|
||||
self.delete(self.wallpaper_id)
|
||||
image = ImageTk.PhotoImage(self.wallpaper)
|
||||
self.redraw_canvas((image.width(), image.height()))
|
||||
self.draw_wallpaper(image)
|
||||
|
||||
def redraw_canvas(self, dimensions=None):
|
||||
logging.info("redrawing canvas to dimensions: %s", dimensions)
|
||||
|
||||
# reset scale and move back to original position
|
||||
logging.info("resetting scaling: %s %s", self.ratio, self.offset)
|
||||
factor = 1 / self.ratio
|
||||
self.scale(tk.ALL, self.offset[0], self.offset[1], factor, factor)
|
||||
self.move(tk.ALL, -self.offset[0], -self.offset[1])
|
||||
|
||||
# reset ratio and offset
|
||||
self.ratio = 1.0
|
||||
self.offset = (0, 0)
|
||||
|
||||
# redraw canvas rectangle
|
||||
self.draw_canvas(dimensions)
|
||||
|
||||
# redraw gridlines to new canvas size
|
||||
self.delete(tags.GRIDLINE)
|
||||
self.draw_grid()
|
||||
self.update_grid()
|
||||
|
||||
def redraw_wallpaper(self):
|
||||
if self.adjust_to_dim.get():
|
||||
logging.info("drawing wallpaper to canvas dimensions")
|
||||
self.resize_to_wallpaper()
|
||||
else:
|
||||
option = ScaleOption(self.scale_option.get())
|
||||
logging.info("drawing canvas using scaling option: %s", option)
|
||||
if option == ScaleOption.UPPER_LEFT:
|
||||
self.wallpaper_upper_left()
|
||||
elif option == ScaleOption.CENTERED:
|
||||
self.wallpaper_center()
|
||||
elif option == ScaleOption.SCALED:
|
||||
self.wallpaper_scaled()
|
||||
elif option == ScaleOption.TILED:
|
||||
logging.warning("tiled background not implemented yet")
|
||||
|
||||
# raise items above wallpaper
|
||||
for component in tags.ABOVE_WALLPAPER_TAGS:
|
||||
self.tag_raise(component)
|
||||
|
||||
def update_grid(self):
|
||||
logging.info("updating grid show: %s", self.show_grid.get())
|
||||
if self.show_grid.get():
|
||||
self.itemconfig(tags.GRIDLINE, state=tk.NORMAL)
|
||||
else:
|
||||
self.itemconfig(tags.GRIDLINE, state=tk.HIDDEN)
|
||||
|
||||
def set_wallpaper(self, filename):
|
||||
logging.info("setting wallpaper: %s", filename)
|
||||
if filename:
|
||||
img = Image.open(filename)
|
||||
self.wallpaper = img
|
||||
self.wallpaper_file = filename
|
||||
self.redraw_wallpaper()
|
||||
else:
|
||||
if self.wallpaper_id is not None:
|
||||
self.delete(self.wallpaper_id)
|
||||
self.wallpaper = None
|
||||
self.wallpaper_file = None
|
||||
|
||||
def is_selection_mode(self):
|
||||
return self.mode == GraphMode.SELECT
|
||||
|
||||
def create_edge(self, source, dest):
|
||||
"""
|
||||
create an edge between source node and destination node
|
||||
|
||||
:param CanvasNode source: source node
|
||||
:param CanvasNode dest: destination node
|
||||
:return: nothing
|
||||
"""
|
||||
if (source.id, dest.id) not in self.edges:
|
||||
pos0 = source.core_node.position
|
||||
x0 = pos0.x
|
||||
y0 = pos0.y
|
||||
edge = CanvasEdge(x0, y0, x0, y0, source.id, self)
|
||||
edge.complete(dest.id)
|
||||
self.edges[edge.token] = edge
|
||||
self.nodes[source.id].edges.add(edge)
|
||||
self.nodes[dest.id].edges.add(edge)
|
||||
self.core.create_link(edge, source, dest)
|
152
daemon/core/gui/graph/linkinfo.py
Normal file
152
daemon/core/gui/graph/linkinfo.py
Normal file
|
@ -0,0 +1,152 @@
|
|||
"""
|
||||
Link information, such as IPv4, IPv6 and throughput drawn in the canvas
|
||||
"""
|
||||
|
||||
from core.api.grpc import core_pb2
|
||||
|
||||
|
||||
class Throughput:
|
||||
def __init__(self, canvas, core):
|
||||
self.canvas = canvas
|
||||
self.core = core
|
||||
# edge canvas id mapped to throughput value
|
||||
self.tracker = {}
|
||||
# map an edge canvas id to a throughput canvas id
|
||||
self.map = {}
|
||||
# map edge canvas id to token
|
||||
self.edge_id_to_token = {}
|
||||
|
||||
def load_throughput_info(self, interface_throughputs):
|
||||
"""
|
||||
load all interface throughouts from an event
|
||||
|
||||
:param repeated core_bp2.InterfaceThroughputinterface_throughputs: interface
|
||||
throughputs
|
||||
:return: nothing
|
||||
"""
|
||||
for throughput in interface_throughputs:
|
||||
nid = throughput.node_id
|
||||
iid = throughput.interface_id
|
||||
tp = throughput.throughput
|
||||
token = self.core.interface_to_edge.get((nid, iid))
|
||||
if token:
|
||||
edge = self.canvas.edges.get(token)
|
||||
if edge:
|
||||
edge_id = edge.id
|
||||
self.edge_id_to_token[edge_id] = token
|
||||
if edge_id not in self.tracker:
|
||||
self.tracker[edge_id] = tp
|
||||
else:
|
||||
temp = self.tracker[edge_id]
|
||||
self.tracker[edge_id] = (temp + tp) / 2
|
||||
else:
|
||||
self.core.interface_to_edge.pop((nid, iid), None)
|
||||
|
||||
def edge_is_wired(self, token):
|
||||
"""
|
||||
determine whether link is a WIRED link
|
||||
|
||||
:param token:
|
||||
:return:
|
||||
"""
|
||||
canvas_edge = self.canvas.edges[token]
|
||||
canvas_src_id = canvas_edge.src
|
||||
canvas_dst_id = canvas_edge.dst
|
||||
src = self.canvas.nodes[canvas_src_id].core_node
|
||||
dst = self.canvas.nodes[canvas_dst_id].core_node
|
||||
return not (
|
||||
src.type == core_pb2.NodeType.WIRELESS_LAN
|
||||
and dst.model == "mdr"
|
||||
or src.model == "mdr"
|
||||
and dst.type == core_pb2.NodeType.WIRELESS_LAN
|
||||
)
|
||||
|
||||
def draw_wired_throughput(self, edge_id):
|
||||
|
||||
x0, y0, x1, y1 = self.canvas.coords(edge_id)
|
||||
x = (x0 + x1) / 2
|
||||
y = (y0 + y1) / 2
|
||||
if edge_id not in self.map:
|
||||
tpid = self.canvas.create_text(
|
||||
x,
|
||||
y,
|
||||
tags="throughput",
|
||||
font=("Arial", 8),
|
||||
text="{0:.3f} kbps".format(0.001 * self.tracker[edge_id]),
|
||||
)
|
||||
self.map[edge_id] = tpid
|
||||
else:
|
||||
tpid = self.map[edge_id]
|
||||
self.canvas.coords(tpid, x, y)
|
||||
self.canvas.itemconfig(
|
||||
tpid, text="{0:.3f} kbps".format(0.001 * self.tracker[edge_id])
|
||||
)
|
||||
|
||||
def draw_wireless_throughput(self, edge_id):
|
||||
token = self.edge_id_to_token[edge_id]
|
||||
canvas_edge = self.canvas.edges[token]
|
||||
canvas_src_id = canvas_edge.src
|
||||
canvas_dst_id = canvas_edge.dst
|
||||
src_node = self.canvas.nodes[canvas_src_id]
|
||||
dst_node = self.canvas.nodes[canvas_dst_id]
|
||||
|
||||
not_wlan = (
|
||||
dst_node
|
||||
if src_node.core_node.type == core_pb2.NodeType.WIRELESS_LAN
|
||||
else src_node
|
||||
)
|
||||
|
||||
x, y = self.canvas.coords(not_wlan.id)
|
||||
if edge_id not in self.map:
|
||||
tp_id = self.canvas.create_text(
|
||||
x + 50,
|
||||
y + 25,
|
||||
font=("Arial", 8),
|
||||
tags="throughput",
|
||||
text="{0:.3f} kbps".format(0.001 * self.tracker[edge_id]),
|
||||
)
|
||||
self.map[edge_id] = tp_id
|
||||
|
||||
# redraw throughput
|
||||
else:
|
||||
self.canvas.itemconfig(
|
||||
self.map[edge_id],
|
||||
text="{0:.3f} kbps".format(0.001 * self.tracker[edge_id]),
|
||||
)
|
||||
|
||||
def draw_throughputs(self):
|
||||
for edge_id in self.tracker:
|
||||
if self.edge_is_wired(self.edge_id_to_token[edge_id]):
|
||||
self.draw_wired_throughput(edge_id)
|
||||
else:
|
||||
self.draw_wireless_throughput(edge_id)
|
||||
|
||||
def process_grpc_throughput_event(self, interface_throughputs):
|
||||
self.load_throughput_info(interface_throughputs)
|
||||
self.draw_throughputs()
|
||||
|
||||
def move(self, edge):
|
||||
tpid = self.map.get(edge.id)
|
||||
if tpid:
|
||||
if self.edge_is_wired(edge.token):
|
||||
x0, y0, x1, y1 = self.canvas.coords(edge.id)
|
||||
self.canvas.coords(tpid, (x0 + x1) / 2, (y0 + y1) / 2)
|
||||
else:
|
||||
if (
|
||||
self.canvas.nodes[edge.src].core_node.type
|
||||
== core_pb2.NodeType.WIRELESS_LAN
|
||||
):
|
||||
x, y = self.canvas.coords(edge.dst)
|
||||
self.canvas.coords(tpid, x + 50, y + 20)
|
||||
else:
|
||||
x, y = self.canvas.coords(edge.src)
|
||||
self.canvas.coords(tpid, x + 50, y + 25)
|
||||
|
||||
def delete(self, edge):
|
||||
tpid = self.map.get(edge.id)
|
||||
if tpid:
|
||||
eid = edge.id
|
||||
self.canvas.delete(tpid)
|
||||
self.tracker.pop(eid)
|
||||
self.map.pop(eid)
|
||||
self.edge_id_to_token.pop(eid)
|
274
daemon/core/gui/graph/node.py
Normal file
274
daemon/core/gui/graph/node.py
Normal file
|
@ -0,0 +1,274 @@
|
|||
import tkinter as tk
|
||||
from tkinter import font
|
||||
|
||||
import grpc
|
||||
|
||||
from core.api.grpc import core_pb2
|
||||
from core.api.grpc.core_pb2 import NodeType
|
||||
from core.gui import themes
|
||||
from core.gui.dialogs.emaneconfig import EmaneConfigDialog
|
||||
from core.gui.dialogs.mobilityconfig import MobilityConfigDialog
|
||||
from core.gui.dialogs.nodeconfig import NodeConfigDialog
|
||||
from core.gui.dialogs.nodeservice import NodeService
|
||||
from core.gui.dialogs.wlanconfig import WlanConfigDialog
|
||||
from core.gui.errors import show_grpc_error
|
||||
from core.gui.graph import tags
|
||||
from core.gui.graph.tooltip import CanvasTooltip
|
||||
from core.gui.nodeutils import NodeUtils
|
||||
|
||||
NODE_TEXT_OFFSET = 5
|
||||
|
||||
|
||||
class CanvasNode:
|
||||
def __init__(self, app, x, y, core_node, image):
|
||||
self.app = app
|
||||
self.canvas = app.canvas
|
||||
self.image = image
|
||||
self.core_node = core_node
|
||||
self.id = self.canvas.create_image(
|
||||
x, y, anchor=tk.CENTER, image=self.image, tags=tags.NODE
|
||||
)
|
||||
text_font = font.Font(family="TkIconFont", size=12)
|
||||
label_y = self._get_label_y()
|
||||
self.text_id = self.canvas.create_text(
|
||||
x,
|
||||
label_y,
|
||||
text=self.core_node.name,
|
||||
tags=tags.NODE_NAME,
|
||||
font=text_font,
|
||||
fill="#0000CD",
|
||||
)
|
||||
self.tooltip = CanvasTooltip(self.canvas)
|
||||
self.edges = set()
|
||||
self.interfaces = []
|
||||
self.wireless_edges = set()
|
||||
self.antennae = []
|
||||
self.setup_bindings()
|
||||
|
||||
def setup_bindings(self):
|
||||
self.canvas.tag_bind(self.id, "<Double-Button-1>", self.double_click)
|
||||
self.canvas.tag_bind(self.id, "<Enter>", self.on_enter)
|
||||
self.canvas.tag_bind(self.id, "<Leave>", self.on_leave)
|
||||
|
||||
def delete(self):
|
||||
self.canvas.delete(self.id)
|
||||
self.canvas.delete(self.text_id)
|
||||
self.delete_antennae()
|
||||
|
||||
def add_antenna(self):
|
||||
x, y = self.canvas.coords(self.id)
|
||||
offset = len(self.antennae) * 8
|
||||
antenna_id = self.canvas.create_image(
|
||||
x - 16 + offset,
|
||||
y - 23,
|
||||
anchor=tk.CENTER,
|
||||
image=NodeUtils.ANTENNA_ICON,
|
||||
tags=tags.ANTENNA,
|
||||
)
|
||||
self.antennae.append(antenna_id)
|
||||
|
||||
def delete_antenna(self):
|
||||
"""
|
||||
delete one antenna
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
if self.antennae:
|
||||
antenna_id = self.antennae.pop()
|
||||
self.canvas.delete(antenna_id)
|
||||
|
||||
def delete_antennae(self):
|
||||
"""
|
||||
delete all antennas
|
||||
|
||||
:return: nothing
|
||||
"""
|
||||
for antenna_id in self.antennae:
|
||||
self.canvas.delete(antenna_id)
|
||||
self.antennae.clear()
|
||||
|
||||
def redraw(self):
|
||||
self.canvas.itemconfig(self.id, image=self.image)
|
||||
self.canvas.itemconfig(self.text_id, text=self.core_node.name)
|
||||
|
||||
def _get_label_y(self):
|
||||
image_box = self.canvas.bbox(self.id)
|
||||
return image_box[3] + NODE_TEXT_OFFSET
|
||||
|
||||
def move(self, x, y):
|
||||
x, y = self.canvas.get_scaled_coords(x, y)
|
||||
current_x, current_y = self.canvas.coords(self.id)
|
||||
x_offset = x - current_x
|
||||
y_offset = y - current_y
|
||||
self.motion(x_offset, y_offset, update=False)
|
||||
|
||||
def motion(self, x_offset, y_offset, update=True):
|
||||
original_position = self.canvas.coords(self.id)
|
||||
self.canvas.move(self.id, x_offset, y_offset)
|
||||
x, y = self.canvas.coords(self.id)
|
||||
|
||||
# check new position
|
||||
bbox = self.canvas.bbox(self.id)
|
||||
if not self.canvas.valid_position(*bbox):
|
||||
self.canvas.coords(self.id, original_position)
|
||||
return
|
||||
|
||||
# move test and selection box
|
||||
self.canvas.move(self.text_id, x_offset, y_offset)
|
||||
self.canvas.move_selection(self.id, x_offset, y_offset)
|
||||
|
||||
# move antennae
|
||||
for antenna_id in self.antennae:
|
||||
self.canvas.move(antenna_id, x_offset, y_offset)
|
||||
|
||||
# move edges
|
||||
for edge in self.edges:
|
||||
x1, y1, x2, y2 = self.canvas.coords(edge.id)
|
||||
if edge.src == self.id:
|
||||
self.canvas.coords(edge.id, x, y, x2, y2)
|
||||
else:
|
||||
self.canvas.coords(edge.id, x1, y1, x, y)
|
||||
self.canvas.throughput_draw.move(edge)
|
||||
edge.update_labels()
|
||||
|
||||
for edge in self.wireless_edges:
|
||||
x1, y1, x2, y2 = self.canvas.coords(edge.id)
|
||||
if edge.src == self.id:
|
||||
self.canvas.coords(edge.id, x, y, x2, y2)
|
||||
else:
|
||||
self.canvas.coords(edge.id, x1, y1, x, y)
|
||||
|
||||
# set actual coords for node and update core is running
|
||||
real_x, real_y = self.canvas.get_actual_coords(x, y)
|
||||
self.core_node.position.x = real_x
|
||||
self.core_node.position.y = real_y
|
||||
if self.app.core.is_runtime() and update:
|
||||
self.app.core.edit_node(self.core_node)
|
||||
|
||||
def on_enter(self, event):
|
||||
if self.app.core.is_runtime() and self.app.core.observer:
|
||||
self.tooltip.text.set("waiting...")
|
||||
self.tooltip.on_enter(event)
|
||||
try:
|
||||
output = self.app.core.run(self.core_node.id)
|
||||
self.tooltip.text.set(output)
|
||||
except grpc.RpcError as e:
|
||||
show_grpc_error(e)
|
||||
|
||||
def on_leave(self, event):
|
||||
self.tooltip.on_leave(event)
|
||||
|
||||
def double_click(self, event):
|
||||
if self.app.core.is_runtime():
|
||||
self.canvas.core.launch_terminal(self.core_node.id)
|
||||
else:
|
||||
self.show_config()
|
||||
|
||||
def create_context(self):
|
||||
is_wlan = self.core_node.type == NodeType.WIRELESS_LAN
|
||||
is_emane = self.core_node.type == NodeType.EMANE
|
||||
context = tk.Menu(self.canvas)
|
||||
themes.style_menu(context)
|
||||
if self.app.core.is_runtime():
|
||||
context.add_command(label="Configure", command=self.show_config)
|
||||
if NodeUtils.is_container_node(self.core_node.type):
|
||||
context.add_command(label="Services", state=tk.DISABLED)
|
||||
if is_wlan:
|
||||
context.add_command(label="WLAN Config", command=self.show_wlan_config)
|
||||
if is_wlan and self.core_node.id in self.app.core.mobility_players:
|
||||
context.add_command(
|
||||
label="Mobility Player", command=self.show_mobility_player
|
||||
)
|
||||
context.add_command(label="Select Adjacent", state=tk.DISABLED)
|
||||
context.add_command(label="Hide", state=tk.DISABLED)
|
||||
if NodeUtils.is_container_node(self.core_node.type):
|
||||
context.add_command(label="Shell Window", state=tk.DISABLED)
|
||||
context.add_command(label="Tcpdump", state=tk.DISABLED)
|
||||
context.add_command(label="Tshark", state=tk.DISABLED)
|
||||
context.add_command(label="Wireshark", state=tk.DISABLED)
|
||||
context.add_command(label="View Log", state=tk.DISABLED)
|
||||
else:
|
||||
context.add_command(label="Configure", command=self.show_config)
|
||||
if NodeUtils.is_container_node(self.core_node.type):
|
||||
context.add_command(label="Services", command=self.show_services)
|
||||
if is_emane:
|
||||
context.add_command(
|
||||
label="EMANE Config", command=self.show_emane_config
|
||||
)
|
||||
if is_wlan:
|
||||
context.add_command(label="WLAN Config", command=self.show_wlan_config)
|
||||
context.add_command(
|
||||
label="Mobility Config", command=self.show_mobility_config
|
||||
)
|
||||
if NodeUtils.is_wireless_node(self.core_node.type):
|
||||
context.add_command(
|
||||
label="Link To Selected", command=self.wireless_link_selected
|
||||
)
|
||||
context.add_command(label="Select Members", state=tk.DISABLED)
|
||||
context.add_command(label="Select Adjacent", state=tk.DISABLED)
|
||||
context.add_command(label="Create Link To", state=tk.DISABLED)
|
||||
context.add_command(label="Assign To", state=tk.DISABLED)
|
||||
context.add_command(label="Move To", state=tk.DISABLED)
|
||||
context.add_command(label="Cut", state=tk.DISABLED)
|
||||
context.add_command(label="Copy", state=tk.DISABLED)
|
||||
context.add_command(label="Paste", state=tk.DISABLED)
|
||||
context.add_command(label="Delete", state=tk.DISABLED)
|
||||
context.add_command(label="Hide", state=tk.DISABLED)
|
||||
return context
|
||||
|
||||
def show_config(self):
|
||||
self.canvas.context = None
|
||||
dialog = NodeConfigDialog(self.app, self.app, self)
|
||||
dialog.show()
|
||||
|
||||
def show_wlan_config(self):
|
||||
self.canvas.context = None
|
||||
dialog = WlanConfigDialog(self.app, self.app, self)
|
||||
dialog.show()
|
||||
|
||||
def show_mobility_config(self):
|
||||
self.canvas.context = None
|
||||
dialog = MobilityConfigDialog(self.app, self.app, self)
|
||||
dialog.show()
|
||||
|
||||
def show_mobility_player(self):
|
||||
self.canvas.context = None
|
||||
mobility_player = self.app.core.mobility_players[self.core_node.id]
|
||||
mobility_player.show()
|
||||
|
||||
def show_emane_config(self):
|
||||
self.canvas.context = None
|
||||
dialog = EmaneConfigDialog(self.app, self.app, self)
|
||||
dialog.show()
|
||||
|
||||
def show_services(self):
|
||||
self.canvas.context = None
|
||||
dialog = NodeService(self.app.master, self.app, self)
|
||||
dialog.show()
|
||||
|
||||
def has_emane_link(self, interface_id):
|
||||
result = None
|
||||
for edge in self.edges:
|
||||
if self.id == edge.src:
|
||||
other_id = edge.dst
|
||||
edge_interface_id = edge.src_interface.id
|
||||
else:
|
||||
other_id = edge.src
|
||||
edge_interface_id = edge.dst_interface.id
|
||||
if edge_interface_id != interface_id:
|
||||
continue
|
||||
other_node = self.canvas.nodes[other_id]
|
||||
if other_node.core_node.type == NodeType.EMANE:
|
||||
result = other_node.core_node
|
||||
break
|
||||
return result
|
||||
|
||||
def wireless_link_selected(self):
|
||||
self.canvas.context = None
|
||||
for canvas_nid in [
|
||||
x for x in self.canvas.selection if "node" in self.canvas.gettags(x)
|
||||
]:
|
||||
core_node = self.canvas.nodes[canvas_nid].core_node
|
||||
if core_node.type == core_pb2.NodeType.DEFAULT and core_node.model == "mdr":
|
||||
self.canvas.create_edge(self, self.canvas.nodes[canvas_nid])
|
||||
self.canvas.clear_selection()
|
179
daemon/core/gui/graph/shape.py
Normal file
179
daemon/core/gui/graph/shape.py
Normal file
|
@ -0,0 +1,179 @@
|
|||
import logging
|
||||
|
||||
from core.gui.dialogs.shapemod import ShapeDialog
|
||||
from core.gui.graph import tags
|
||||
from core.gui.graph.shapeutils import ShapeType
|
||||
|
||||
|
||||
class AnnotationData:
|
||||
def __init__(
|
||||
self,
|
||||
text="",
|
||||
font="Arial",
|
||||
font_size=12,
|
||||
text_color="#000000",
|
||||
fill_color="",
|
||||
border_color="#000000",
|
||||
border_width=1,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
):
|
||||
self.text = text
|
||||
self.font = font
|
||||
self.font_size = font_size
|
||||
self.text_color = text_color
|
||||
self.fill_color = fill_color
|
||||
self.border_color = border_color
|
||||
self.border_width = border_width
|
||||
self.bold = bold
|
||||
self.italic = italic
|
||||
self.underline = underline
|
||||
|
||||
|
||||
class Shape:
|
||||
def __init__(self, app, canvas, shape_type, x1, y1, x2=None, y2=None, data=None):
|
||||
self.app = app
|
||||
self.canvas = canvas
|
||||
self.shape_type = shape_type
|
||||
self.id = None
|
||||
self.text_id = None
|
||||
self.x1 = x1
|
||||
self.y1 = y1
|
||||
if x2 is None:
|
||||
x2 = x1
|
||||
self.x2 = x2
|
||||
if y2 is None:
|
||||
y2 = y1
|
||||
self.y2 = y2
|
||||
if data is None:
|
||||
self.created = False
|
||||
self.shape_data = AnnotationData()
|
||||
else:
|
||||
self.created = True
|
||||
self.shape_data = data
|
||||
self.draw()
|
||||
|
||||
def draw(self):
|
||||
if self.created:
|
||||
dash = None
|
||||
else:
|
||||
dash = "-"
|
||||
if self.shape_type == ShapeType.OVAL:
|
||||
self.id = self.canvas.create_oval(
|
||||
self.x1,
|
||||
self.y1,
|
||||
self.x2,
|
||||
self.y2,
|
||||
tags=tags.SHAPE,
|
||||
dash=dash,
|
||||
fill=self.shape_data.fill_color,
|
||||
outline=self.shape_data.border_color,
|
||||
width=self.shape_data.border_width,
|
||||
)
|
||||
self.draw_shape_text()
|
||||
elif self.shape_type == ShapeType.RECTANGLE:
|
||||
self.id = self.canvas.create_rectangle(
|
||||
self.x1,
|
||||
self.y1,
|
||||
self.x2,
|
||||
self.y2,
|
||||
tags=tags.SHAPE,
|
||||
dash=dash,
|
||||
fill=self.shape_data.fill_color,
|
||||
outline=self.shape_data.border_color,
|
||||
width=self.shape_data.border_width,
|
||||
)
|
||||
self.draw_shape_text()
|
||||
elif self.shape_type == ShapeType.TEXT:
|
||||
font = self.get_font()
|
||||
self.id = self.canvas.create_text(
|
||||
self.x1,
|
||||
self.y1,
|
||||
tags=tags.SHAPE_TEXT,
|
||||
text=self.shape_data.text,
|
||||
fill=self.shape_data.text_color,
|
||||
font=font,
|
||||
)
|
||||
else:
|
||||
logging.error("unknown shape type: %s", self.shape_type)
|
||||
self.created = True
|
||||
|
||||
def get_font(self):
|
||||
font = [self.shape_data.font, self.shape_data.font_size]
|
||||
if self.shape_data.bold:
|
||||
font.append("bold")
|
||||
if self.shape_data.italic:
|
||||
font.append("italic")
|
||||
if self.shape_data.underline:
|
||||
font.append("underline")
|
||||
return font
|
||||
|
||||
def draw_shape_text(self):
|
||||
if self.shape_data.text:
|
||||
x = (self.x1 + self.x2) / 2
|
||||
y = self.y1 + 1.5 * self.shape_data.font_size
|
||||
font = self.get_font()
|
||||
self.text_id = self.canvas.create_text(
|
||||
x,
|
||||
y,
|
||||
tags=tags.SHAPE_TEXT,
|
||||
text=self.shape_data.text,
|
||||
fill=self.shape_data.text_color,
|
||||
font=font,
|
||||
)
|
||||
|
||||
def shape_motion(self, x1, y1):
|
||||
self.canvas.coords(self.id, self.x1, self.y1, x1, y1)
|
||||
|
||||
def shape_complete(self, x, y):
|
||||
for component in tags.ABOVE_SHAPE:
|
||||
self.canvas.tag_raise(component)
|
||||
s = ShapeDialog(self.app, self.app, self)
|
||||
s.show()
|
||||
|
||||
def disappear(self):
|
||||
self.canvas.delete(self.id)
|
||||
|
||||
def motion(self, x_offset, y_offset):
|
||||
original_position = self.canvas.coords(self.id)
|
||||
self.canvas.move(self.id, x_offset, y_offset)
|
||||
coords = self.canvas.coords(self.id)
|
||||
if not self.canvas.valid_position(*coords):
|
||||
self.canvas.coords(self.id, original_position)
|
||||
return
|
||||
|
||||
self.canvas.move_selection(self.id, x_offset, y_offset)
|
||||
if self.text_id is not None:
|
||||
self.canvas.move(self.text_id, x_offset, y_offset)
|
||||
|
||||
def delete(self):
|
||||
self.canvas.delete(self.id)
|
||||
self.canvas.delete(self.text_id)
|
||||
|
||||
def metadata(self):
|
||||
coords = self.canvas.coords(self.id)
|
||||
# update coords to actual positions
|
||||
if len(coords) == 4:
|
||||
x1, y1, x2, y2 = coords
|
||||
x1, y1 = self.canvas.get_actual_coords(x1, y1)
|
||||
x2, y2 = self.canvas.get_actual_coords(x2, y2)
|
||||
coords = (x1, y1, x2, y2)
|
||||
else:
|
||||
x1, y1 = coords
|
||||
x1, y1 = self.canvas.get_actual_coords(x1, y1)
|
||||
coords = (x1, y1)
|
||||
return {
|
||||
"type": self.shape_type.value,
|
||||
"iconcoords": coords,
|
||||
"label": self.shape_data.text,
|
||||
"fontfamily": self.shape_data.font,
|
||||
"fontsize": self.shape_data.font_size,
|
||||
"labelcolor": self.shape_data.text_color,
|
||||
"color": self.shape_data.fill_color,
|
||||
"border": self.shape_data.border_color,
|
||||
"width": self.shape_data.border_width,
|
||||
"bold": self.shape_data.bold,
|
||||
"italic": self.shape_data.italic,
|
||||
"underline": self.shape_data.underline,
|
||||
}
|
23
daemon/core/gui/graph/shapeutils.py
Normal file
23
daemon/core/gui/graph/shapeutils.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
import enum
|
||||
|
||||
|
||||
class ShapeType(enum.Enum):
|
||||
MARKER = "marker"
|
||||
OVAL = "oval"
|
||||
RECTANGLE = "rectangle"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
SHAPES = {ShapeType.OVAL, ShapeType.RECTANGLE}
|
||||
|
||||
|
||||
def is_draw_shape(shape_type):
|
||||
return shape_type in SHAPES
|
||||
|
||||
|
||||
def is_shape_text(shape_type):
|
||||
return shape_type == ShapeType.TEXT
|
||||
|
||||
|
||||
def is_marker(shape_type):
|
||||
return shape_type == ShapeType.MARKER
|
35
daemon/core/gui/graph/tags.py
Normal file
35
daemon/core/gui/graph/tags.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
GRIDLINE = "gridline"
|
||||
SHAPE = "shape"
|
||||
SHAPE_TEXT = "shapetext"
|
||||
EDGE = "edge"
|
||||
LINK_INFO = "linkinfo"
|
||||
WIRELESS_EDGE = "wireless"
|
||||
ANTENNA = "antenna"
|
||||
NODE_NAME = "nodename"
|
||||
NODE = "node"
|
||||
WALLPAPER = "wallpaper"
|
||||
SELECTION = "selectednodes"
|
||||
ABOVE_WALLPAPER_TAGS = [
|
||||
GRIDLINE,
|
||||
SHAPE,
|
||||
SHAPE_TEXT,
|
||||
EDGE,
|
||||
LINK_INFO,
|
||||
WIRELESS_EDGE,
|
||||
ANTENNA,
|
||||
NODE,
|
||||
NODE_NAME,
|
||||
]
|
||||
ABOVE_SHAPE = [GRIDLINE, EDGE, LINK_INFO, WIRELESS_EDGE, ANTENNA, NODE, NODE_NAME]
|
||||
COMPONENT_TAGS = [
|
||||
EDGE,
|
||||
NODE,
|
||||
NODE_NAME,
|
||||
WALLPAPER,
|
||||
LINK_INFO,
|
||||
ANTENNA,
|
||||
WIRELESS_EDGE,
|
||||
SELECTION,
|
||||
SHAPE,
|
||||
SHAPE_TEXT,
|
||||
]
|
103
daemon/core/gui/graph/tooltip.py
Normal file
103
daemon/core/gui/graph/tooltip.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from core.gui.themes import Styles
|
||||
|
||||
|
||||
class CanvasTooltip:
|
||||
"""
|
||||
It creates a tooltip for a given canvas tag or id as the mouse is
|
||||
above it.
|
||||
|
||||
This class has been derived from the original Tooltip class updated
|
||||
and posted back to StackOverflow at the following link:
|
||||
|
||||
https://stackoverflow.com/questions/3221956/
|
||||
what-is-the-simplest-way-to-make-tooltips-in-tkinter/
|
||||
41079350#41079350
|
||||
|
||||
Alberto Vassena on 2016.12.10.
|
||||
"""
|
||||
|
||||
def __init__(self, canvas, *, pad=(5, 3, 5, 3), waittime=400, wraplength=600):
|
||||
# in miliseconds, originally 500
|
||||
self.waittime = waittime
|
||||
# in pixels, originally 180
|
||||
self.wraplength = wraplength
|
||||
self.canvas = canvas
|
||||
self.text = tk.StringVar()
|
||||
self.pad = pad
|
||||
self.id = None
|
||||
self.tw = None
|
||||
|
||||
def on_enter(self, event=None):
|
||||
self.schedule()
|
||||
|
||||
def on_leave(self, event=None):
|
||||
self.unschedule()
|
||||
self.hide()
|
||||
|
||||
def schedule(self):
|
||||
self.unschedule()
|
||||
self.id = self.canvas.after(self.waittime, self.show)
|
||||
|
||||
def unschedule(self):
|
||||
id_ = self.id
|
||||
self.id = None
|
||||
if id_:
|
||||
self.canvas.after_cancel(id_)
|
||||
|
||||
def show(self, event=None):
|
||||
def tip_pos_calculator(canvas, label, *, tip_delta=(10, 5), pad=(5, 3, 5, 3)):
|
||||
c = canvas
|
||||
s_width, s_height = c.winfo_screenwidth(), c.winfo_screenheight()
|
||||
width, height = (
|
||||
pad[0] + label.winfo_reqwidth() + pad[2],
|
||||
pad[1] + label.winfo_reqheight() + pad[3],
|
||||
)
|
||||
mouse_x, mouse_y = c.winfo_pointerxy()
|
||||
x1, y1 = mouse_x + tip_delta[0], mouse_y + tip_delta[1]
|
||||
x2, y2 = x1 + width, y1 + height
|
||||
|
||||
x_delta = x2 - s_width
|
||||
if x_delta < 0:
|
||||
x_delta = 0
|
||||
y_delta = y2 - s_height
|
||||
if y_delta < 0:
|
||||
y_delta = 0
|
||||
|
||||
offscreen = (x_delta, y_delta) != (0, 0)
|
||||
if offscreen:
|
||||
if x_delta:
|
||||
x1 = mouse_x - tip_delta[0] - width
|
||||
if y_delta:
|
||||
y1 = mouse_y - tip_delta[1] - height
|
||||
offscreen_again = y1 < 0 # out on the top
|
||||
if offscreen_again:
|
||||
y1 = 0
|
||||
return x1, y1
|
||||
|
||||
pad = self.pad
|
||||
canvas = self.canvas
|
||||
|
||||
# creates a toplevel window
|
||||
self.tw = tk.Toplevel(canvas.master)
|
||||
|
||||
# Leaves only the label and removes the app window
|
||||
self.tw.wm_overrideredirect(True)
|
||||
win = ttk.Frame(self.tw, style=Styles.tooltip_frame, padding=3)
|
||||
win.grid()
|
||||
label = ttk.Label(
|
||||
win,
|
||||
textvariable=self.text,
|
||||
wraplength=self.wraplength,
|
||||
style=Styles.tooltip,
|
||||
)
|
||||
label.grid(padx=(pad[0], pad[2]), pady=(pad[1], pad[3]), sticky=tk.NSEW)
|
||||
x, y = tip_pos_calculator(canvas, label, pad=pad)
|
||||
self.tw.wm_geometry("+%d+%d" % (x, y))
|
||||
|
||||
def hide(self):
|
||||
if self.tw:
|
||||
self.tw.destroy()
|
||||
self.tw = None
|
Loading…
Add table
Add a link
Reference in a new issue