Coverage for /home/pi/Software/model-railway-signalling/model_railway_signals/editor/schematic.py: 97%
515 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-05 17:29 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-05 17:29 +0100
1#------------------------------------------------------------------------------------
2# This Module provides all the internal functions for editing the layout schematic
3# in terms of adding/removing objects, drag/drop objects, copy/paste objects etc
4#------------------------------------------------------------------------------------
5#
6# External API functions intended for use by other editor modules:
7# initialise(root, callback, width, height, grid, snap) - Call once on startup
8# update_canvas(width,height,grid,snap) - Call following a size update (or layout load/canvas resize)
9# delete_all_objects() - To delete all objects for layout 'new' and layout 'load'
10# enable_editing() - Call when 'Edit' Mode is selected (via toolbar or on load)
11# disable_editing() - Call when 'Run' Mode is selected (via toolbar or on load)
12#
13# Makes the following external API calls to other editor modules:
14# objects.initialise (canvas,width,height,grid) - Initialise the objects package and set defaults
15# objects.update_canvas(width,height,grid) - update the attributes (on layout load or canvas re-size)
16# objects.create_object(obj, type, subtype) - Create a default object on the schematic
17# objects.delete_objects(list of obj IDs) - Delete the selected objects from the canvas
18# objects.rotate_objects(list of obj IDs) - Rotate the selected objects on the canvas
19# objects.copy_objects(list of obj IDs) - Copy the selected objects to the clipboard
20# objects.paste_objects() - Paste the selected objects (returns a list of new IDs)
21# objects.undo() / objects.redo() - Undo and re-do functions as you would expect
22# objects.enable_editing() - Call when 'Edit' Mode is selected
23# objects.disable_editing() - Call when 'Run' Mode is selected
24# configure_signal.edit_signal(root,object_id) - Open signal edit window (on double click)
25# configure_point.edit_point(root,object_id) - Open point edit window (on double click)
26# configure_section.edit_section(root,object_id) - Open section edit window (on double click)
27# configure_instrument.edit_instrument(root,object_id) - Open inst edit window (on double click)
28# configure_line.edit_line(root,object_id) - Open line edit window (on double click)
29# configure_textbox.edit_textbox(root,object_id) - Open textbox edit window (on double click)
30# configure_track_sensor.edit_track_sensor(root,object_id) - Open the edit window (on double click)
31#
32# Accesses the following external editor objects directly:
33# objects.schematic_objects - the dict holding descriptions for all objects
34# objects.object_type - used to establish the type of the schematic objects
35#
36# Accesses the following external library objects directly:
37# signals_common.sig_type - Used to access the signal type
38# signals_colour_lights.signal_sub_type - Used to access the signal subtype
39# signals_semaphores.semaphore_sub_type - Used to access the signal subtype
40# signals_ground_position.ground_pos_sub_type - Used to access the signal subtype
41# signals_ground_disc.ground_disc_sub_type - Used to access the signal subtype
42# block_instruments.instrument_type - Used to access the block_instrument type
43# points.point_type - Used to access the point type
44#
45#------------------------------------------------------------------------------------
47import tkinter as Tk
49from ..library import signals_common
50from ..library import signals_colour_lights
51from ..library import signals_semaphores
52from ..library import signals_ground_position
53from ..library import signals_ground_disc
54from ..library import block_instruments
55from ..library import points
57from . import objects
58from . import configure_signal
59from . import configure_point
60from . import configure_section
61from . import configure_instrument
62from . import configure_line
63from . import configure_textbox
64from . import configure_track_sensor
66import importlib.resources
67import math
68import copy
70#------------------------------------------------------------------------------------
71# Global variables used to track the current selections/state of the Schematic Editor
72#------------------------------------------------------------------------------------
74# The schematic_state dict holds the current schematic editor status
75schematic_state:dict = {}
76schematic_state["startx"] = 0
77schematic_state["starty"] = 0
78schematic_state["lastx"] = 0
79schematic_state["lasty"] = 0
80schematic_state["moveobjects"] = False
81schematic_state["editlineend1"] = False
82schematic_state["editlineend2"] = False
83schematic_state["selectarea"] = False
84schematic_state["selectareabox"] = None # Tkinter drawing object
85schematic_state["selectedobjects"] = []
86# The Root reference is used when calling a "configure object" module (to open a popup window)
87# The Canvas reference is used for configuring and moving canvas widgets for schematic editing
88# canvas_width / canvas_height / canvas_grid are used for positioning of objects.
89root = None
90canvas = None
91canvas_width = 0
92canvas_height = 0
93canvas_grid = 0
94canvas_grid_state = "normal"
95canvas_snap_to_grid = True
96# The callback to make (for selected canvas events) - Mode change, toggle Snap to Grid etc
97# event makes this callback (to enable the application mode to be toggled between edit and run)
98canvas_event_callback = None
99# The following Tkinter objects are also treated as global variables as they need to remain
100# "in scope" for the schematic editor functions (i.e. so they don't get garbage collected)
101# The two popup menus (for right click on the canvas or a schematic object)
102popup1 = None
103popup2 = None
104# This global reference to the popup edit window class is maintained for test purposes
105edit_popup = None
106# The Frame holding the "add object" buttons (for pack/forget on enable/disable editing)
107button_frame = None
108# The list of button images (which needs to be kept in scope)
109button_images = []
111#------------------------------------------------------------------------------------
112# Internal Function to return the absolute canvas coordinates for an event
113# (which take into account any canvas scroll bar offsets)
114#------------------------------------------------------------------------------------
116def canvas_coordinates(event):
117 canvas = event.widget
118 x = canvas.canvasx(event.x)
119 y = canvas.canvasy(event.y)
120 return(x, y)
122#------------------------------------------------------------------------------------
123# Internal Function to draw (or redraw) the grid on the screen (after re-sizing)
124# Uses the global canvas_width, canvas_height, canvas_grid variables
125#------------------------------------------------------------------------------------
127def draw_grid():
128 # Note we leave the 'state' of the grid unchanged when re-drawing
129 # As the 'state' is set (normal or hidden) when enabling/disabling editing
130 canvas.delete("grid")
131 canvas.create_rectangle(0, 0, canvas_width, canvas_height, outline='#999', fill="", tags="grid", state=canvas_grid_state)
132 for i in range(0, canvas_height, canvas_grid):
133 canvas.create_line(0,i,canvas_width,i,fill='#999',tags="grid",state=canvas_grid_state)
134 for i in range(0, canvas_width, canvas_grid):
135 canvas.create_line(i,0,i,canvas_height,fill='#999',tags="grid",state=canvas_grid_state)
136 # Push the grid to the back (behind any drawing objects)
137 canvas.tag_lower("grid")
138 return()
140#------------------------------------------------------------------------------------
141# Internal function to create an object (and make it the only selected object)
142#------------------------------------------------------------------------------------
144def create_object(new_object_type, item_type=None, item_subtype=None):
145 deselect_all_objects()
146 object_id = objects.create_object(new_object_type, item_type, item_subtype)
147 select_object(object_id)
148 return()
150#------------------------------------------------------------------------------------
151# Internal function to select an object (adding to the list of selected objects)
152#------------------------------------------------------------------------------------
154def select_object(object_id):
155 global schematic_state
156 # Add the specified object to the list of selected objects
157 schematic_state["selectedobjects"].append(object_id)
158 # Highlight the item to show it has been selected
159 if objects.schematic_objects[object_id]["item"] == objects.object_type.line:
160 canvas.itemconfigure(objects.schematic_objects[object_id]["end1"],state="normal")
161 canvas.itemconfigure(objects.schematic_objects[object_id]["end2"],state="normal")
162 else:
163 canvas.itemconfigure(objects.schematic_objects[object_id]["bbox"],state="normal")
164 return()
166#------------------------------------------------------------------------------------
167# Internal function to deselect an object (removing from the list of selected objects)
168#------------------------------------------------------------------------------------
170def deselect_object(object_id):
171 global schematic_state
172 # remove the specified object from the list of selected objects
173 schematic_state["selectedobjects"].remove(object_id)
174 # Remove the highlighting to show it has been de-selected
175 if objects.schematic_objects[object_id]["item"] == objects.object_type.line:
176 canvas.itemconfigure(objects.schematic_objects[object_id]["end1"],state="hidden")
177 canvas.itemconfigure(objects.schematic_objects[object_id]["end2"],state="hidden")
178 else:
179 canvas.itemconfigure(objects.schematic_objects[object_id]["bbox"],state="hidden")
180 return()
182#------------------------------------------------------------------------------------
183# Internal function to select all objects on the layout schematic
184#------------------------------------------------------------------------------------
186def select_all_objects(event=None):
187 global schematic_state
188 # Clear out the list of selected objects first
189 schematic_state["selectedobjects"] = []
190 for object_id in objects.schematic_objects:
191 select_object(object_id)
192 return()
194#------------------------------------------------------------------------------------
195# Internal function to deselect all objects (clearing the list of selected objects)
196#------------------------------------------------------------------------------------
198def deselect_all_objects(event=None):
199 selections = copy.deepcopy(schematic_state["selectedobjects"])
200 for object_id in selections:
201 deselect_object(object_id)
202 return()
204#------------------------------------------------------------------------------------
205# Internal function to delete all objects (for layout 'load' and layout 'new')
206#------------------------------------------------------------------------------------
208def delete_all_objects():
209 global schematic_state
210 # Select and delete all objects (also clear the selected objects list)
211 select_all_objects()
212 # Delete the objects from the schematic
213 objects.delete_objects(schematic_state["selectedobjects"],initialise_layout_after_delete=False)
214 # Remove the objects from the list of selected objects
215 schematic_state["selectedobjects"]=[]
216 # Belt and braces delete of all canvas objects as I've seen issues when
217 # running the system tests (probably because I'm not using the mainloop)
218 canvas.delete("all")
219 # Set the select area box to 'None' so it gets created on first use
220 schematic_state["selectareabox"] = None
221 return()
223#------------------------------------------------------------------------------------
224# Internal function to edit an object configuration (double-click and popup menu)
225# Only a single Object will be selected when this function is called
226#------------------------------------------------------------------------------------
228def edit_selected_object():
229 global edit_popup
230 object_id = schematic_state["selectedobjects"][0]
231 if objects.schematic_objects[object_id]["item"] == objects.object_type.line:
232 edit_popup = configure_line.edit_line(root, object_id)
233 elif objects.schematic_objects[object_id]["item"] == objects.object_type.textbox:
234 edit_popup = configure_textbox.edit_textbox(root, object_id)
235 elif objects.schematic_objects[object_id]["item"] == objects.object_type.signal:
236 edit_popup = configure_signal.edit_signal(root, object_id)
237 elif objects.schematic_objects[object_id]["item"] == objects.object_type.point:
238 edit_popup = configure_point.edit_point(root, object_id)
239 elif objects.schematic_objects[object_id]["item"] == objects.object_type.section:
240 edit_popup = configure_section.edit_section(root,object_id)
241 elif objects.schematic_objects[object_id]["item"] == objects.object_type.instrument:
242 edit_popup = configure_instrument.edit_instrument(root,object_id)
243 elif objects.schematic_objects[object_id]["item"] == objects.object_type.track_sensor: 243 ↛ 245line 243 didn't jump to line 245, because the condition on line 243 was never false
244 edit_popup = configure_track_sensor.edit_track_sensor(root,object_id)
245 return()
247# The following function is for test purposes only - to close the windows opened above by the system tests
249def close_edit_window (ok:bool=False, cancel:bool=False, apply:bool=False, reset:bool=False):
250 global edit_popup
251 if ok: edit_popup.save_state(close_window=True)
252 elif apply: edit_popup.save_state(close_window=False)
253 elif cancel: edit_popup.close_window()
254 elif reset: edit_popup.load_state()
256#------------------------------------------------------------------------------------
257# Internal function to snap all selected objects to the grid ('s' keypress). We do
258# this an object at a time as each object may require different offsets to be applied.
259# Note that for lines, we need to snap each end to the grid seperately because the
260# line ends may have been previously edited with snap-to-grid disabled. If we just
261# snap the line to the grid, line end 2 may still be off the grid.
262#------------------------------------------------------------------------------------
264def snap_selected_objects_to_grid(event=None):
265 for object_id in schematic_state["selectedobjects"]:
266 posx = objects.schematic_objects[object_id]["posx"]
267 posy = objects.schematic_objects[object_id]["posy"]
268 xdiff1,ydiff1 = snap_to_grid(posx,posy, force_snap=True)
269 if objects.schematic_objects[object_id]["item"] == objects.object_type.line:
270 posx = objects.schematic_objects[object_id]["endx"]
271 posy = objects.schematic_objects[object_id]["endy"]
272 xdiff2,ydiff2 = snap_to_grid(posx,posy, force_snap=True)
273 move_line_end_1(object_id,xdiff1,ydiff1)
274 objects.move_objects([object_id],xdiff1=xdiff1,ydiff1=ydiff1)
275 move_line_end_2(object_id,xdiff2,ydiff2)
276 objects.move_objects([object_id],xdiff2=xdiff2,ydiff2=ydiff2)
277 else:
278 canvas.move(objects.schematic_objects[object_id]["tags"],xdiff1,ydiff1)
279 canvas.move(objects.schematic_objects[object_id]["bbox"],xdiff1,ydiff1)
280 objects.move_objects([object_id],xdiff1=xdiff1, ydiff1=ydiff1, xdiff2=xdiff1, ydiff2=ydiff1)
281 return()
283#------------------------------------------------------------------------------------
284# Internal function to nudge all selected objects (move on arrow keys). Note that we
285# Throttle the key-repeat (user holding the arrow key down) so Tkinter can keep up.
286# If nothing is selected, then we scroll the canvas instead
287#------------------------------------------------------------------------------------
289def nudge_selected_objects(event=None):
290 disable_arrow_keypress_events()
291 # Move the selected objects or scroll the canvas if nothing selected
292 if schematic_state["selectedobjects"] == []:
293 canvas.focus_set()
294 canvas.config(xscrollincrement=canvas_grid, yscrollincrement=canvas_grid)
295 if event.keysym == 'Left': canvas.xview_scroll(-1, "units")
296 if event.keysym == 'Right': canvas.xview_scroll(1, "units")
297 if event.keysym == 'Up': canvas.yview_scroll(-1, "units")
298 if event.keysym == 'Down': canvas.yview_scroll(1, "units")
299 else:
300 if canvas_snap_to_grid: delta = canvas_grid
301 else: delta = 1
302 if event.keysym == 'Left': xdiff, ydiff = -delta, 0
303 if event.keysym == 'Right': xdiff, ydiff = delta, 0
304 if event.keysym == 'Up': xdiff, ydiff = 0, -delta
305 if event.keysym == 'Down': xdiff, ydiff = 0, delta
306 move_selected_objects(xdiff,ydiff)
307 objects.move_objects(schematic_state["selectedobjects"],xdiff1=xdiff, ydiff1=ydiff, xdiff2=xdiff, ydiff2=ydiff)
308 canvas.after(50,enable_arrow_keypress_events)
309 return()
311def enable_arrow_keypress_events(event=None):
312 canvas.bind('<KeyPress-Left>',nudge_selected_objects)
313 canvas.bind('<KeyPress-Right>',nudge_selected_objects)
314 canvas.bind('<KeyPress-Up>',nudge_selected_objects)
315 canvas.bind('<KeyPress-Down>',nudge_selected_objects)
316 return()
318def disable_arrow_keypress_events(event=None):
319 canvas.unbind('<KeyPress-Left>')
320 canvas.unbind('<KeyPress-Right>')
321 canvas.unbind('<KeyPress-Up>')
322 canvas.unbind('<KeyPress-Down>')
323 return()
325#------------------------------------------------------------------------------------
326# Internal function to edit a line object on the canvas. The "editlineend1" and
327# "editlineend2" dictionary elements specify the line end that needs to be moved
328# Only a single Object will be selected when this function is called
329#------------------------------------------------------------------------------------
331def update_end_stops(object_id):
332 # Update the line end stops using the common function provided by the objects sub-package
333 x1,y1,x2,y2 = canvas.coords(objects.schematic_objects[object_id]["line"])
334 dx,dy = objects.get_endstop_offsets(x1,y1,x2,y2)
335 canvas.coords(objects.schematic_objects[object_id]["stop1"],x1+dx,y1+dy,x1-dx,y1-dy)
336 canvas.coords(objects.schematic_objects[object_id]["stop2"],x2+dx,y2+dy,x2-dx,y2-dy)
337 return()
339def move_line_end_1(object_id, xdiff:int,ydiff:int):
340 # Move the tkinter selection circle for the 'start' of the line
341 canvas.move(objects.schematic_objects[object_id]["end1"],xdiff,ydiff)
342 # Update the line coordinates to reflect the changed 'start' position
343 end2x = objects.schematic_objects[object_id]["endx"]
344 end2y = objects.schematic_objects[object_id]["endy"]
345 x1,y1,x2,y2 = canvas.coords(objects.schematic_objects[object_id]["end1"])
346 canvas.coords(objects.schematic_objects[object_id]["line"],(x1+x2)/2,(y1+y2)/2,end2x,end2y)
347 # Update the position of the line end stops to reflect the new line geometry
348 update_end_stops(object_id)
349 return()
351def move_line_end_2(object_id, xdiff:int,ydiff:int):
352 # Move the tkinter selection circle for the 'end' of the line
353 canvas.move(objects.schematic_objects[object_id]["end2"],xdiff,ydiff)
354 # Update the line coordinates to reflect the changed 'end' position
355 end1x = objects.schematic_objects[object_id]["posx"]
356 end1y = objects.schematic_objects[object_id]["posy"]
357 x1,y1,x2,y2 = canvas.coords(objects.schematic_objects[object_id]["end2"])
358 canvas.coords(objects.schematic_objects[object_id]["line"],end1x,end1y,(x1+x2)/2,(y1+y2)/2)
359 # Update the position of the line end stops to reflect the new line geometry
360 update_end_stops(object_id)
361 return()
363#------------------------------------------------------------------------------------
364# Internal function to move all selected objects on the canvas
365#------------------------------------------------------------------------------------
367def move_selected_objects(xdiff:int,ydiff:int):
368 for object_id in schematic_state["selectedobjects"]:
369 # All drawing objects should be "tagged" apart from the bbox
370 canvas.move(objects.schematic_objects[object_id]["tags"],xdiff,ydiff)
371 canvas.move(objects.schematic_objects[object_id]["bbox"],xdiff,ydiff)
372 return()
374#------------------------------------------------------------------------------------
375# Internal function to Delete all selected objects (delete/backspace and popup menu)
376#------------------------------------------------------------------------------------
378def delete_selected_objects(event=None):
379 global schematic_state
380 # Delete the objects from the schematic
381 objects.delete_objects(schematic_state["selectedobjects"])
382 # Remove the objects from the list of selected objects
383 schematic_state["selectedobjects"]=[]
384 return()
386#------------------------------------------------------------------------------------
387# Internal function to Rotate all selected Objects ('r' key and popup menu)
388#------------------------------------------------------------------------------------
390def rotate_selected_objects(event=None):
391 objects.rotate_objects(schematic_state["selectedobjects"])
392 return()
394#------------------------------------------------------------------------------------
395# Internal function to Copy selected objects to the clipboard (Cntl-c and popup menu)
396#------------------------------------------------------------------------------------
398def copy_selected_objects(event=None):
399 objects.copy_objects(schematic_state["selectedobjects"])
400 return()
402#------------------------------------------------------------------------------------
403# Internal function to paste previously copied objects (Cntl-V and popup menu)
404#------------------------------------------------------------------------------------
406def paste_clipboard_objects(event=None):
407 # Paste the objects and re-copy (for a subsequent paste)
408 list_of_new_object_ids = objects.paste_objects()
409 objects.copy_objects(list_of_new_object_ids)
410 # Select the pasted objects (in case the user wants to paste again)
411 deselect_all_objects()
412 for object_id in list_of_new_object_ids:
413 select_object(object_id)
414 return()
416#------------------------------------------------------------------------------------
417# Internal function to return the ID of the Object the cursor is "highlighting"
418# Returns the UUID of the highlighted item add details of the highlighted element
419# Main = (True,False), Secondary = (False, True), All = (True, True)
420#------------------------------------------------------------------------------------
422def find_highlighted_object(xpos:int,ypos:int):
423 # We do this in a certain order - objects first then lines so we don't
424 # select a line that is under the object the user is trying to select
425 for object_id in objects.schematic_objects:
426 bbox = canvas.coords(objects.schematic_objects[object_id]["bbox"])
427 if objects.schematic_objects[object_id]["item"] != objects.object_type.line:
428 if bbox[0] < xpos and bbox[2] > xpos and bbox[1] < ypos and bbox[3] > ypos:
429 return(object_id)
430 for object_id in objects.schematic_objects:
431 # For lines we need to check if the cursor is "close" to the line
432 if objects.schematic_objects[object_id]["item"] == objects.object_type.line:
433 x1 = objects.schematic_objects[object_id]["posx"]
434 x2 = objects.schematic_objects[object_id]["endx"]
435 y1 = objects.schematic_objects[object_id]["posy"]
436 y2 = objects.schematic_objects[object_id]["endy"]
437 a, b, c = y1-y2, x2-x1,(x1-x2)*y1 + (y2-y1)*x1
438 if ( ( (xpos>x1 and xpos<x2) or (xpos>x2 and xpos<x1) or
439 (ypos>y1 and ypos<y2) or (ypos>y2 and ypos<y1) ) and
440 ( (abs(a * xpos + b * ypos + c)) / math.sqrt(a * a + b * b)) <= 5 ):
441 return(object_id)
442 return(None)
444#------------------------------------------------------------------------------------
445# Internal function to return the ID of the Object the cursor is "highlighting"
446# Returns the UUID of the highlighted item add details of the highlighted element
447# Main = (True,False), Secondary = (False, True), All = (True, True)
448#------------------------------------------------------------------------------------
450def find_highlighted_line_end(xpos:int,ypos:int):
451 global schematic_state
452 # Iterate through the selected objects to see if any "line ends" are selected
453 for object_id in schematic_state["selectedobjects"]:
454 if objects.schematic_objects[object_id]["item"] == objects.object_type.line:
455 x1 = objects.schematic_objects[object_id]["posx"]
456 x2 = objects.schematic_objects[object_id]["endx"]
457 y1 = objects.schematic_objects[object_id]["posy"]
458 y2 = objects.schematic_objects[object_id]["endy"]
459 if math.sqrt((xpos - x1) ** 2 + (ypos - y1) ** 2) <= 10:
460 schematic_state["editlineend1"] = True
461 schematic_state["editlineend2"] = False
462 return(object_id)
463 elif math.sqrt((xpos - x2) ** 2 + (ypos - y2) ** 2) <= 10:
464 schematic_state["editlineend1"] = False
465 schematic_state["editlineend2"] = True
466 return(object_id)
467 return(None)
469#------------------------------------------------------------------------------------
470# Internal function to Snap the given coordinates to a grid (by returning the deltas)
471# Uses the global canvas_grid variable
472#------------------------------------------------------------------------------------
474def snap_to_grid(xpos:int,ypos:int, force_snap:bool=False):
475 if canvas_snap_to_grid or force_snap:
476 remainderx = xpos%canvas_grid
477 remaindery = ypos%canvas_grid
478 if remainderx < canvas_grid/2: remainderx = 0 - remainderx
479 else: remainderx = canvas_grid - remainderx
480 if remaindery < canvas_grid/2: remaindery = 0 - remaindery
481 else: remaindery = canvas_grid - remaindery
482 else:
483 remainderx = 0
484 remaindery = 0
485 return(remainderx,remaindery)
487#------------------------------------------------------------------------------------
488# Right Button Click - Bring Up Context specific Popup menu
489# The event will only be bound to the canvas in "Edit" Mode
490#------------------------------------------------------------------------------------
492def right_button_click(event):
493 # Find the object at the current cursor position (if there is one)
494 # Note that we use the the canvas coordinates to see if the cursor
495 # is over the object (as these take into account the current scroll
496 # bar positions) and the event root coordinates for the popup
497 canvas_x, canvas_y = canvas_coordinates(event)
498 highlighted_object = find_highlighted_object(canvas_x, canvas_y)
499 if highlighted_object:
500 # Clear any current selections and select the highlighted item
501 deselect_all_objects()
502 select_object(highlighted_object)
503 # Enable the Object popup menu (which will be for the selected object)
504 popup1.tk_popup(event.x_root,event.y_root)
505 else:
506 # Enable the canvas popup menu
507 popup2.tk_popup(event.x_root,event.y_root)
508 return()
510#------------------------------------------------------------------------------------
511# Left Button Click - Select Object and/or Start of one the following functions:
512# Initiate a Move of Selected Objects / Edit Selected Line / Select Area
513# The event will only be bound to the canvas in "Edit" Mode
514#------------------------------------------------------------------------------------
516def left_button_click(event):
517 global schematic_state
518 # set keyboard focus for the canvas (so that any key bindings will work)
519 canvas.focus_set()
520 # Get the canvas coordinates (to take into account any scroll bar offsets)
521 canvas_x, canvas_y = canvas_coordinates(event)
522 schematic_state["startx"] = canvas_x
523 schematic_state["starty"] = canvas_y
524 schematic_state["lastx"] = canvas_x
525 schematic_state["lasty"] = canvas_y
526 # See if the cursor is over the "end" of an already selected line
527 highlighted_object = find_highlighted_line_end(canvas_x,canvas_y)
528 if highlighted_object:
529 # Clear selections and select the highlighted line. Note that the edit line
530 # mode ("editline1" or "editline2") get set by "find_highlighted_line_end"
531 deselect_all_objects()
532 select_object(highlighted_object)
533 else:
534 # See if the cursor is over any other canvas object
535 highlighted_object = find_highlighted_object(canvas_x,canvas_y)
536 if highlighted_object:
537 schematic_state["moveobjects"] = True
538 if highlighted_object not in schematic_state["selectedobjects"]:
539 # Clear any current selections and select the highlighted object
540 deselect_all_objects()
541 select_object(highlighted_object)
542 else:
543 # Cursor is not over any object - Could be the start of a new area selection or
544 # just clearing the current selection - In either case we deselect all objects
545 deselect_all_objects()
546 schematic_state["selectarea"] = True
547 # Make the 'selectareabox' visible. This will create the box on first use
548 # or after a 'delete_all_objects (when the box is set to 'None')
549 if schematic_state["selectareabox"] is None:
550 schematic_state["selectareabox"] = canvas.create_rectangle(0,0,0,0,outline="orange")
551 canvas.coords(schematic_state["selectareabox"],canvas_x,canvas_y,canvas_x,canvas_y)
552 canvas.itemconfigure(schematic_state["selectareabox"],state="normal")
553 # Unbind the canvas keypresses until left button release to prevent mode changes,
554 # rotate/delete of objects (i.e. prevent undesirable editor behavior)
555 disable_all_keypress_events_during_move()
556 return()
558#------------------------------------------------------------------------------------
559# Left-Shift-Click - Select/deselect Object
560# The event will only be bound to the canvas in "Edit" Mode
561#------------------------------------------------------------------------------------
563def left_shift_click(event):
564 # Get the canvas coordinates (to take into account any scroll bar offsets)
565 canvas_x, canvas_y = canvas_coordinates(event)
566 # Find the object at the current cursor position (if there is one)
567 highlighted_object = find_highlighted_object(canvas_x,canvas_y)
568 if highlighted_object and highlighted_object in schematic_state["selectedobjects"]:
569 # Deselect just the highlighted object (leave everything else selected)
570 deselect_object(highlighted_object)
571 else:
572 # Select the highlighted object to the list of selected objects
573 select_object(highlighted_object)
574 return()
576#------------------------------------------------------------------------------------
577# Left-Double-Click - Bring up edit object dialog for object
578# The event will only be bound to the canvas in "Edit" Mode
579#------------------------------------------------------------------------------------
581def left_double_click(event):
582 # Get the canvas coordinates (to take into account any scroll bar offsets)
583 canvas_x, canvas_y = canvas_coordinates(event)
584 # Find the object at the current cursor position (if there is one)
585 highlighted_object = find_highlighted_object(canvas_x,canvas_y)
586 if highlighted_object: 586 ↛ 592line 586 didn't jump to line 592, because the condition on line 586 was never false
587 # Clear any current selections and select the highlighted item
588 deselect_all_objects()
589 select_object(highlighted_object)
590 # Call the function to open the edit dialog for the object
591 edit_selected_object()
592 return()
594#------------------------------------------------------------------------------------
595# Track Cursor - Move Selected Objects / Edit Selected Line / Change Area Selection
596# The event will only be bound to the canvas in "Edit" Mode
597#------------------------------------------------------------------------------------
599def track_cursor(event):
600 global schematic_state
601 # Get the canvas coordinates (to take into account any scroll bar offsets)
602 canvas_x, canvas_y = canvas_coordinates(event)
603 if schematic_state["moveobjects"]:
604 # Work out the delta movement since the last re-draw
605 xdiff = canvas_x - schematic_state["lastx"]
606 ydiff = canvas_y - schematic_state["lasty"]
607 # Move all the objects that are selected
608 move_selected_objects(xdiff,ydiff)
609 # Set the 'last' position for the next move event
610 schematic_state["lastx"] += xdiff
611 schematic_state["lasty"] += ydiff
612 elif schematic_state["editlineend1"] or schematic_state["editlineend2"]:
613 xdiff = canvas_x - schematic_state["lastx"]
614 ydiff = canvas_y - schematic_state["lasty"]
615 # Move the selected line end (only one object will be selected)
616 object_id = schematic_state["selectedobjects"][0]
617 if schematic_state["editlineend1"]: move_line_end_1(object_id,xdiff,ydiff)
618 else: move_line_end_2(object_id,xdiff,ydiff)
619 # Reset the "start" position for the next move
620 schematic_state["lastx"] += xdiff
621 schematic_state["lasty"] += ydiff
622 elif schematic_state["selectarea"]:
623 # Dynamically resize the selection area
624 x1 = schematic_state["startx"]
625 y1 = schematic_state["starty"]
626 canvas.coords(schematic_state["selectareabox"],x1,y1,canvas_x,canvas_y)
627 return()
629#------------------------------------------------------------------------------------
630# Left Button Release - Finish Object or line end Moves (by snapping to grid)
631# or select all objects within the canvas area selection box
632# The event will only be bound to the canvas in "Edit" Mode
633#------------------------------------------------------------------------------------
635def left_button_release(event):
636 global schematic_state
637 if schematic_state["moveobjects"]:
638 # Finish the move by snapping all objects to the grid - we only need to work
639 # out the xdiff and xdiff for one of the selected objects to get the diff
640 xdiff,ydiff = snap_to_grid(schematic_state["lastx"]- schematic_state["startx"],
641 schematic_state["lasty"]- schematic_state["starty"])
642 move_selected_objects(xdiff,ydiff)
643 # Calculate the total deltas for the move (from the startposition)
644 finalx = schematic_state["lastx"] - schematic_state["startx"] + xdiff
645 finaly = schematic_state["lasty"] - schematic_state["starty"] + ydiff
646 # Finalise the move by updating the current object position
647 objects.move_objects(schematic_state["selectedobjects"],
648 xdiff1=finalx, ydiff1=finaly, xdiff2=finalx, ydiff2=finaly )
649 # Clear the "select object mode" - but leave all objects selected
650 schematic_state["moveobjects"] = False
651 elif schematic_state["editlineend1"] or schematic_state["editlineend2"]:
652 # Finish the move by snapping the line end to the grid
653 xdiff,ydiff = snap_to_grid(schematic_state["lastx"]- schematic_state["startx"],
654 schematic_state["lasty"]- schematic_state["starty"])
655 # Move the selected line end (only one object will be selected)
656 object_id = schematic_state["selectedobjects"][0]
657 if schematic_state["editlineend1"]: move_line_end_1(object_id,xdiff,ydiff)
658 else: move_line_end_2(object_id,xdiff,ydiff)
659 # Calculate the total deltas for the move (from the startposition)
660 finalx = schematic_state["lastx"] - schematic_state["startx"] + xdiff
661 finaly = schematic_state["lasty"] - schematic_state["starty"] + ydiff
662 # Finalise the move by updating the current object position
663 if schematic_state["editlineend1"]:
664 objects.move_objects(schematic_state["selectedobjects"], xdiff1=finalx, ydiff1=finaly)
665 else:
666 objects.move_objects(schematic_state["selectedobjects"], xdiff2=finalx, ydiff2=finaly)
667 # Clear the "Edit line mode" - but leave the line selected
668 schematic_state["editlineend1"] = False
669 schematic_state["editlineend2"] = False
670 # Note the defensive programming - to ensure the bbox exists
671 elif schematic_state["selectarea"] and schematic_state["selectareabox"] is not None:
672 # Select all Objects that are fully within the Area Selection Box
673 abox = canvas.coords(schematic_state["selectareabox"])
674 for object_id in objects.schematic_objects:
675 bbox = canvas.coords(objects.schematic_objects[object_id]["bbox"])
676 if bbox[0]>abox[0] and bbox[2]<abox[2] and bbox[1]>abox[1] and bbox[3]<abox[3]:
677 select_object(object_id)
678 # Clear the Select Area Mode and Hide the area selection rectangle
679 canvas.itemconfigure(schematic_state["selectareabox"],state="hidden")
680 schematic_state["selectarea"] = False
681 # Re-bind the canvas keypresses on completion of area selection or Move Objects
682 enable_all_keypress_events_after_completion_of_move()
683 return()
685#------------------------------------------------------------------------------------
686# Left Button Release - Finish Object or line end Moves (by snapping to grid)
687# or select all objects within the canvas area selection box
688# The event will only be bound to the canvas in "Edit" Mode
689#------------------------------------------------------------------------------------
691def cancel_move_in_progress(event=None):
692 global schematic_state
693 if schematic_state["moveobjects"]:
694 # Undo the move by returning all objects to their start position
695 xdiff = schematic_state["startx"] - schematic_state["lastx"]
696 ydiff = schematic_state["starty"] - schematic_state["lasty"]
697 move_selected_objects(xdiff,ydiff)
698 # Clear the "select object mode" - but leave all objects selected
699 schematic_state["moveobjects"] = False
700 elif schematic_state["editlineend1"] or schematic_state["editlineend2"]:
701 # Undo the move by returning all objects to their start position
702 xdiff = schematic_state["startx"] - schematic_state["lastx"]
703 ydiff = schematic_state["starty"] - schematic_state["lasty"]
704 # Move the selected line end (only one object will be selected)
705 object_id = schematic_state["selectedobjects"][0]
706 if schematic_state["editlineend1"]: move_line_end_1(object_id,xdiff,ydiff)
707 else: move_line_end_2(object_id,xdiff,ydiff)
708 # Clear the "Edit line mode" - but leave the line selected
709 schematic_state["editlineend1"] = False
710 schematic_state["editlineend2"] = False
711 # Note the defensive programming - to ensure the bbox exists
712 elif schematic_state["selectarea"] and schematic_state["selectareabox"] is not None: 712 ↛ 717line 712 didn't jump to line 717, because the condition on line 712 was never false
713 # Clear the Select Area Mode and Hide the area selection rectangle
714 canvas.itemconfigure(schematic_state["selectareabox"],state="hidden")
715 schematic_state["selectarea"] = False
716 # Re-bind the canvas keypresses on completion of area selection or Move Objects
717 enable_all_keypress_events_after_completion_of_move()
718 return()
720#------------------------------------------------------------------------------------
721# Externally called Function to resize the canvas (called from menubar module on load
722# of new schematic or re-size of canvas via menubar). Updates the global variables
723#------------------------------------------------------------------------------------
725def update_canvas(width:int, height:int, grid:int, snap_to_grid:bool):
726 global canvas_width, canvas_height, canvas_grid, canvas_snap_to_grid
727 # Update the tkinter canvas object if the size has been updated
728 if canvas_width != width or canvas_height != height:
729 canvas_width, canvas_height = width, height
730 reset_window_size()
731 else:
732 canvas_width, canvas_height = width, height
733 # Set the global variables (used in the 'draw_grid' function)
734 canvas_grid, canvas_snap_to_grid = grid, snap_to_grid
735 draw_grid()
736 # Also update the objects module with the new settings
737 objects.update_canvas(canvas_width, canvas_height, canvas_grid)
738 return()
740#------------------------------------------------------------------------------------
741# Function to reset the root window sizeto fit the canvas. Note that the maximum
742# window size will have been been set to the screen size on application creation
743#------------------------------------------------------------------------------------
745def reset_window_size(event=None):
746 global canvas_width, canvas_height
747 canvas.config(width=canvas_width, height=canvas_height, scrollregion=(0,0,canvas_width,canvas_height))
748 root.geometry("")
749 return()
751#------------------------------------------------------------------------------------
752# Undo and re-do functions (to deselect all objects first)
753#------------------------------------------------------------------------------------
755def schematic_undo(event=None):
756 deselect_all_objects()
757 objects.undo()
758 return()
760def schematic_redo(event=None):
761 deselect_all_objects()
762 objects.redo()
763 return()
765#------------------------------------------------------------------------------------
766# Internal Functions to enable/disable all canvas keypress events during an object
767# move, line edit or area selection function (to ensure deterministic behavior).
768# Note that on disable (when a move or area selection has been initiated) then we
769# re-bind the escape key to the function for canceling the move / area selection.
770# on enable (at completion or cancel of the move/area seclection) then the Escape
771# key will be re-bound to 'deselect_all_objects' in 'enable_edit_keypress_events'
772#------------------------------------------------------------------------------------
774def enable_all_keypress_events_after_completion_of_move():
775 enable_edit_keypress_events()
776 enable_arrow_keypress_events()
777 canvas.bind('<Control-Key-m>', canvas_event_callback) # Toggle Mode (Edit/Run)
778 canvas.bind('<Control-Key-r>', reset_window_size)
779 return()
781def disable_all_keypress_events_during_move():
782 disable_edit_keypress_events()
783 disable_arrow_keypress_events()
784 canvas.bind('<Escape>',cancel_move_in_progress)
785 canvas.unbind('<Control-Key-m>') # Toggle Mode (Edit/Run)
786 canvas.unbind('<Control-Key-r>') # Toggle Mode (Edit/Run)
787 return()
789#------------------------------------------------------------------------------------
790# Internal Functions to enable/disable all edit-mode specific keypress events on
791# edit enable / edit disable) - all keypress events apart from the mode toggle event
792#------------------------------------------------------------------------------------
794def enable_edit_keypress_events():
795 canvas.bind('<BackSpace>', delete_selected_objects)
796 canvas.bind('<Delete>', delete_selected_objects)
797 canvas.bind('<Escape>', deselect_all_objects)
798 canvas.bind('<Control-Key-c>', copy_selected_objects)
799 canvas.bind('<Control-Key-v>', paste_clipboard_objects)
800 canvas.bind('<Control-Key-z>', schematic_undo)
801 canvas.bind('<Control-Key-y>', schematic_redo)
802 canvas.bind('<Control-Key-s>', canvas_event_callback) # Toggle Snap to Grid Mode
803 canvas.bind('r', rotate_selected_objects)
804 canvas.bind('s', snap_selected_objects_to_grid)
805 return()
807def disable_edit_keypress_events():
808 canvas.unbind('<BackSpace>')
809 canvas.unbind('<Delete>')
810 canvas.unbind('<Escape>')
811 canvas.unbind('<Control-Key-c>')
812 canvas.unbind('<Control-Key-v>')
813 canvas.unbind('<Control-Key-z>')
814 canvas.unbind('<Control-Key-y>')
815 canvas.unbind('<Control-Key-s>')
816 canvas.unbind('r')
817 canvas.unbind('s')
818 return()
820#------------------------------------------------------------------------------------
821# Externally called Functions to enable/disable schematic editing
822# Either from the Menubar Mode selection or the 'm' key
823#------------------------------------------------------------------------------------
825def enable_editing():
826 global canvas_grid_state
827 canvas_grid_state = "normal"
828 canvas.itemconfig("grid",state=canvas_grid_state)
829 # Enable editing of the schematic objects
830 objects.enable_editing()
831 # Re-pack the subframe containing the "add object" buttons to display it. Note that we
832 # first 'forget' the canvas_frame and then re-pack the button_frame first, followed by
833 # the canvas_frame - this ensures that the buttons don't dissapear on window re-size
834 canvas_frame.forget()
835 button_frame.pack(side=Tk.LEFT, expand=False, fill=Tk.BOTH)
836 canvas_frame.pack(side=Tk.LEFT, expand=True, fill=Tk.BOTH)
837 # Bind the Canvas mouse and button events to the various callback functions
838 canvas.bind("<Motion>", track_cursor)
839 canvas.bind('<Button-1>', left_button_click)
840 canvas.bind('<Button-2>', right_button_click)
841 canvas.bind('<Button-3>', right_button_click)
842 canvas.bind('<Shift-Button-1>', left_shift_click)
843 canvas.bind('<ButtonRelease-1>', left_button_release)
844 canvas.bind('<Double-Button-1>', left_double_click)
845 # Bind the canvas keypresses to the associated functions
846 enable_edit_keypress_events()
847 # Bind the Toggle Mode, arrow key and window re-size keypress events (active in Edit
848 # and Run Modes - only disabled during Edit Mode object moves and area selections)
849 canvas.bind('<Control-Key-r>', reset_window_size)
850 canvas.bind('<Control-Key-m>', canvas_event_callback)
851 enable_arrow_keypress_events()
852 # Layout Automation toggle is disabled in Edit Mode (only enabled in Run Mode)
853 canvas.unbind('<Control-Key-a>')
854 return()
856def disable_editing():
857 global canvas_grid_state
858 canvas_grid_state = "hidden"
859 canvas.itemconfig("grid",state=canvas_grid_state)
860 deselect_all_objects()
861 # Disable editing of the schematic objects
862 objects.disable_editing()
863 # Forget the subframe containing the "add object" buttons to hide it
864 button_frame.forget()
865 # Unbind the Canvas mouse and button events in Run Mode
866 canvas.unbind("<Motion>")
867 canvas.unbind('<Button-1>')
868 canvas.unbind('<Button-2>')
869 canvas.unbind('<Button-3>')
870 canvas.unbind('<Shift-Button-1>')
871 canvas.unbind('<ButtonRelease-1>')
872 canvas.unbind('<Double-Button-1>')
873 # Unbind the canvas keypresses in Run Mode (apart from 'm' to toggle modes)
874 disable_edit_keypress_events()
875 # Bind the Toggle Mode, arrow key and window re-size keypress events (active in Edit
876 # and Run Modes - only disabled during Edit Mode object moves and area selections)
877 canvas.bind('<Control-Key-r>', reset_window_size)
878 canvas.bind('<Control-Key-m>', canvas_event_callback)
879 enable_arrow_keypress_events()
880 # Layout Automation toggle is only enabled in Run Mode (disabled in Edit Mode)
881 canvas.bind('<Control-Key-a>', canvas_event_callback)
882 return()
884#------------------------------------------------------------------------------------
885# Externally Called Initialisation function for the Canvas object
886#------------------------------------------------------------------------------------
888def initialise (root_window, event_callback, width:int, height:int, grid:int, snap_to_grid:bool, edit_mode:bool):
889 global root, canvas, popup1, popup2
890 global canvas_width, canvas_height, canvas_grid, canvas_grid_state, canvas_snap_to_grid
891 global button_frame, canvas_frame, button_images
892 global canvas_event_callback
893 root = root_window
894 canvas_event_callback = event_callback
895 # Create a frame to hold the two subframes ("add" buttons and drawing canvas)
896 frame = Tk.Frame(root_window)
897 frame.pack (expand=True, fill=Tk.BOTH)
898 # Note that we pack the button_frame first, followed by the canvas_frame
899 # This ensures that the buttons don't dissapear on window re-size (shrink)
900 # Create a subframe to hold the "add" buttons
901 button_frame = Tk.Frame(frame, borderwidth=1)
902 button_frame.pack(side=Tk.LEFT, expand=True, fill=Tk.BOTH)
903 # Create a subframe to hold the canvas and scrollbars
904 canvas_frame = Tk.Frame(frame, borderwidth=1)
905 canvas_frame.pack(side=Tk.LEFT, expand=True, fill=Tk.BOTH)
906 # Save the Default values for the canvas as global variables
907 canvas_width, canvas_height, canvas_grid, canvas_snap_to_grid = width, height, grid, snap_to_grid
908 if edit_mode: canvas_grid_state = "normal" 908 ↛ 909line 908 didn't jump to line 909, because the condition on line 908 was never false
909 else: canvas_grid_state = "hidden"
910 # Create the canvas and scrollbars inside the parent frame
911 # We also set focus on the canvas so the keypress events will take effect
912 canvas = Tk.Canvas(canvas_frame ,bg="grey85", scrollregion=(0, 0, canvas_width, canvas_height))
913 canvas.focus_set()
914 hbar = Tk.Scrollbar(canvas_frame, orient=Tk.HORIZONTAL)
915 hbar.pack(side=Tk.BOTTOM, fill=Tk.X)
916 hbar.config(command=canvas.xview)
917 vbar = Tk.Scrollbar(canvas_frame, orient=Tk.VERTICAL)
918 vbar.pack(side=Tk.RIGHT, fill=Tk.Y)
919 vbar.config(command=canvas.yview)
920 canvas.config(width=canvas_width, height=canvas_height)
921 canvas.config(xscrollcommand=hbar.set, yscrollcommand=vbar.set)
922 canvas.pack(side=Tk.LEFT, expand=True, fill=Tk.BOTH)
923 # Define the Object Popup menu for Right Click (something selected)
924 popup1 = Tk.Menu(tearoff=0)
925 popup1.add_command(label="Copy", command=copy_selected_objects)
926 popup1.add_command(label="Edit", command=edit_selected_object)
927 popup1.add_command(label="Rotate", command=rotate_selected_objects)
928 popup1.add_command(label="Delete", command=delete_selected_objects)
929 popup1.add_command(label="Snap to Grid", command=snap_selected_objects_to_grid)
930 # Define the Canvas Popup menu for Right Click (nothing selected)
931 popup2 = Tk.Menu(tearoff=0)
932 popup2.add_command(label="Paste", command=paste_clipboard_objects)
933 popup2.add_command(label="Select all", command=select_all_objects)
934 # Define the object buttons [filename, function_to_call]
935 selections = [ ["textbox", lambda:create_object(objects.object_type.textbox) ], 935 ↛ exitline 935 didn't run the lambda on line 935
936 ["line", lambda:create_object(objects.object_type.line) ],
937 ["colourlight", lambda:create_object(objects.object_type.signal,
938 signals_common.sig_type.colour_light.value,
939 signals_colour_lights.signal_sub_type.four_aspect.value) ],
940 ["semaphore", lambda:create_object(objects.object_type.signal,
941 signals_common.sig_type.semaphore.value,
942 signals_semaphores.semaphore_sub_type.home.value) ],
943 ["groundpos", lambda:create_object(objects.object_type.signal,
944 signals_common.sig_type.ground_position.value,
945 signals_ground_position.ground_pos_sub_type.standard.value) ],
946 ["grounddisc", lambda:create_object(objects.object_type.signal,
947 signals_common.sig_type.ground_disc.value,
948 signals_ground_disc.ground_disc_sub_type.standard.value) ],
949 ["lhpoint", lambda:create_object(objects.object_type.point,
950 points.point_type.LH.value) ],
951 ["rhpoint", lambda:create_object(objects.object_type.point,
952 points.point_type.RH.value) ],
953 ["section", lambda:create_object(objects.object_type.section) ],
954 ["sensor", lambda:create_object(objects.object_type.track_sensor) ],
955 ["instrument", lambda:create_object(objects.object_type.instrument,
956 block_instruments.instrument_type.single_line.value) ] ]
957 # Create the buttons we need (Note that the button images are added to a global
958 # list so they remain in scope (otherwise the buttons won't work)
959 resource_folder = 'model_railway_signals.editor.resources'
960 for index, button in enumerate (selections):
961 file_name = selections[index][0]
962 try:
963 # Load the image file for the button if there is one
964 with importlib.resources.path (resource_folder,(file_name+'.png')) as file_path:
965 button_image = Tk.PhotoImage(file=file_path)
966 button_images.append(button_image)
967 button = Tk.Button (button_frame, image=button_image,command=selections[index][1])
968 button.pack(padx=2, pady=2, fill='x')
969 except:
970 # Else fall back to using a text label (filename) for the button
971 button = Tk.Button (button_frame, text=selections[index][0],command=selections[index][1], bg="grey85")
972 button.pack(padx=2, pady=2, fill='x')
973 # Initialise the Objects package with the required parameters
974 objects.initialise(canvas, canvas_width, canvas_height, canvas_grid)
975 return()
977# The following shutdown function is to overcome what seems to be a bug in TkInter where
978# (I think) Tkinter is trying to destroy the photo-image objects after closure of the
979# root window and this generates the following exception:
980# Exception ignored in: <function Image.__del__ at 0xb57ce6a8>
981# Traceback (most recent call last):
982# File "/usr/lib/python3.7/tkinter/__init__.py", line 3508, in __del__
983# TypeError: catching classes that do not inherit from BaseException is not allowed
985def shutdown():
986 global button_images
987 button_images = []
989####################################################################################