Link View: every link of a component, drawn around it
Right-clicking a component now offers Link View alongside Properties, from the wiresheet and from the navigation tree alike. It takes over the editor pane the same way and answers one question -- what is wired to this component, and where does it go. The chosen component sits in the middle wearing the stroke a selected box wears on the sheet, with what links into it on the left and what it links out to on the right, so every arrow points the way the signal travels. Pale yellow ground and no grid: nothing here can be positioned, because the layout is derived from the links every time it is drawn and no part of it is stored in the file. Read-only throughout -- no drag, no add, no delete -- so a component's wiring can be read without the risk of rearranging it. Every line is grey, since this is not a wiresheet and a wire-blue line would invite the reader to treat it as one. A link on the same sheet is solid; one that crosses is dashed, keeps the grey knob that sheet paints on the slot, and has the far component drawn in with its sheet named beneath it. A reference the document cannot resolve gets a placeholder naming the path, because a link the view silently dropped would be the one thing a reader could not find out from here. Clicking a wire or a component follows the link: it opens the sheet of the end that is not the component the view is about, with the link selected in red. A link that stays on one sheet therefore goes back to that sheet where both ends are drawn, and one that crosses opens the other sheet -- the end the view could only show as a knob. Supporting changes: - draw_component_box is factored out of render_components, so the sheet and this view paint a component with the same code and a box cannot read differently between them. draw_knob takes its canvas for the same reason. - The editor pane's hand-over is now shared: _take_editor_pane, _give_editor_pane_back and close_editor_panel replace the Properties-only versions, so "you have not saved that" is still asked once, in one place, whichever panel has the pane. - Fixes the jump to a link's other sheet, which arrived with the link not selected. An after_idle callback is not late enough -- ttk dispatches its own selection event later than one of those, so the repaint wiped the selection. The link is left in _pending_link_selection for on_tree_select to honour once it has painted. - Each gap is sized to hold a lane per wire crossing it, and lanes are handed out nearest-target-first, so a fan of a dozen links nests instead of crowding a dozen verticals into a 30px band. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,13 +50,15 @@ not inside the window. Tk always hands it to the system menu bar.
|
|||||||
| `undo.py` | Undo/redo stack of reversible DOM commands | no |
|
| `undo.py` | Undo/redo stack of reversible DOM commands | no |
|
||||||
| `wiresheet.py` | Grid, component boxes, link routing and painting | yes |
|
| `wiresheet.py` | Grid, component boxes, link routing and painting | yes |
|
||||||
| `properties.py` | The Properties view: every slot of one component, read and written | yes |
|
| `properties.py` | The Properties view: every slot of one component, read and written | yes |
|
||||||
|
| `link_view.py` | The Link View: every link touching one component, drawn around it | yes |
|
||||||
| `sax_file.py` | Open/save, navigation tree, cascading delete | yes |
|
| `sax_file.py` | Open/save, navigation tree, cascading delete | yes |
|
||||||
| `main.py` | Widgets, menus, event handlers, dialogs, main loop | yes |
|
| `main.py` | Widgets, menus, event handlers, dialogs, main loop | yes |
|
||||||
| `run.py` | Entry point | yes |
|
| `run.py` | Entry point | yes |
|
||||||
|
|
||||||
Imports form a DAG — `sax_dom`/`schema` depend on nothing UI-shaped, `wiresheet` and
|
Imports form a DAG — `sax_dom`/`schema` depend on nothing UI-shaped, `wiresheet` and
|
||||||
`sax_file` build on them, `properties` builds on `schema` and `undo`, and `main` wires it
|
`sax_file` build on them, `properties` builds on `schema` and `undo`, `link_view` builds
|
||||||
together. Event handlers live in `main`, which is what keeps the graph acyclic.
|
on `wiresheet`, and `main` wires it together. Event handlers live in `main`, which is what
|
||||||
|
keeps the graph acyclic.
|
||||||
|
|
||||||
### Shared state
|
### Shared state
|
||||||
|
|
||||||
@@ -133,23 +135,36 @@ walk cannot rewrite the trail it is following.
|
|||||||
### The component popup
|
### The component popup
|
||||||
|
|
||||||
Right-clicking a component offers the same actions from the wiresheet and from the
|
Right-clicking a component offers the same actions from the wiresheet and from the
|
||||||
navigation tree -- Properties, Delete Component, Link Mark, and Link From / Link To naming
|
navigation tree -- Properties, Link View, Delete Component, Link Mark, and Link From /
|
||||||
the marked component once one is marked. `fill_component_popup` builds both, and its entries close
|
Link To naming the marked component once one is marked. `fill_component_popup` builds
|
||||||
over the component rather than reading a selection back, which is what lets each popup act
|
both, and its entries close over the component rather than reading a selection back,
|
||||||
on the row or box that was actually clicked. The tree's root row keeps its own popup.
|
which is what lets each popup act on the row or box that was actually clicked. The tree's
|
||||||
|
root row keeps its own popup.
|
||||||
|
|
||||||
|
### The editor pane
|
||||||
|
|
||||||
|
The pane to the right of the tree holds one of three things: the **wiresheet**, the
|
||||||
|
**Properties view** or the **Link View**. The latter two take the pane over rather than
|
||||||
|
floating above it, so a sheet and a table of one component's slots are never both half in
|
||||||
|
view. The canvas is unpacked, never destroyed, so it keeps its items and its scroll
|
||||||
|
position and **Close** puts it back exactly as it was. It is still painted while it is
|
||||||
|
hidden, which costs nothing and means there is no catching up to do when it returns.
|
||||||
|
|
||||||
|
`_take_editor_pane` and `_give_editor_pane_back` in `main` are the hand-over, and
|
||||||
|
`close_editor_panel` is the single way out of whichever panel has the pane: "you have not
|
||||||
|
saved that" is therefore asked once, in one place, whether the user pressed Close, picked
|
||||||
|
another sheet, walked the history or opened a different component. Only the Properties
|
||||||
|
view has anything to ask about.
|
||||||
|
|
||||||
|
Choosing a node in the tree means "show me that sheet", so it stands a panel down rather
|
||||||
|
than leaving the selection looking like it did nothing. So does deleting the component one
|
||||||
|
is showing, from anywhere -- every delete in the UI goes through `delete_component`,
|
||||||
|
because `sax_file` does its own view refresh rather than going through `refresh_all_views`.
|
||||||
|
|
||||||
### The Properties view
|
### The Properties view
|
||||||
|
|
||||||
**Properties**, from either component popup, **takes over the editor pane** -- the sheet
|
**Properties**, from either component popup, is the one place a slot's value is meant to
|
||||||
and the slot table are the same piece of screen, so only one of them is ever the thing
|
be changed, and the only view here that writes to the document.
|
||||||
being looked at. The canvas is unpacked, never destroyed, so it keeps its items and its
|
|
||||||
scroll position and **Close** puts it back exactly as it was. It is still painted while it
|
|
||||||
is hidden, which costs nothing and means there is no catching up to do when it returns.
|
|
||||||
|
|
||||||
Choosing a node in the tree means "show me that sheet", so it stands the view down rather
|
|
||||||
than leaving the selection looking like it did nothing. So does deleting the component it
|
|
||||||
is showing, from anywhere -- every delete in the UI goes through `delete_component`,
|
|
||||||
because `sax_file` does its own view refresh rather than going through `refresh_all_views`.
|
|
||||||
|
|
||||||
It lists every slot the manifest declares -- bar one kind, see **Hidden slots** below --
|
It lists every slot the manifest declares -- bar one kind, see **Hidden slots** below --
|
||||||
in manifest order, each row numbered with the same slot id the wiresheet box uses. The
|
in manifest order, each row numbered with the same slot id the wiresheet box uses. The
|
||||||
@@ -348,6 +363,46 @@ an `Entry`, a `TCombobox`, a `Text`. A backspace aimed at a text field must neve
|
|||||||
component. Save still checks the component is in the document before writing, since an
|
component. Save still checks the component is in the document before writing, since an
|
||||||
undo can take it out from under the view.
|
undo can take it out from under the view.
|
||||||
|
|
||||||
|
### The Link View
|
||||||
|
|
||||||
|
**Link View**, from either component popup, answers one question -- what is wired to this
|
||||||
|
component, and where does it go -- and takes over the editor pane to do it. It is
|
||||||
|
read-only: no drag, no add, no delete, nothing that writes to the document, so a
|
||||||
|
component's wiring can be read without the risk of rearranging it.
|
||||||
|
|
||||||
|
The chosen component is drawn in the middle, with the components linking **into** it on
|
||||||
|
the left and the ones it links **out to** on the right, so every arrow points the way the
|
||||||
|
signal travels and the picture reads left to right the way a wiresheet does. The chosen
|
||||||
|
box wears the same dark stroke a selected box wears on the sheet. The ground is **pale
|
||||||
|
yellow** and carries **no grid**: nothing here can be positioned, because the layout is
|
||||||
|
derived from the links every time it is drawn and no part of it is stored in the file
|
||||||
|
(`meta` records a component's place on its own sheet, which says nothing about where it
|
||||||
|
belongs relative to another one).
|
||||||
|
|
||||||
|
Every line is **grey**, because this is not a wiresheet and a wire-blue line would invite
|
||||||
|
the reader to treat it as one:
|
||||||
|
|
||||||
|
| Link | Drawn as |
|
||||||
|
|---|---|
|
||||||
|
| link on the same wiresheet | solid grey line |
|
||||||
|
| link to another wiresheet | dashed grey line, plus the grey knob that sheet paints on the slot, with the far component drawn in and its sheet named beneath it |
|
||||||
|
|
||||||
|
Boxes come from `wiresheet.draw_component_box` -- the same function the sheet paints
|
||||||
|
with -- so a component reads exactly as it does on its own sheet and a wire lands on the
|
||||||
|
same slot row. A component wired twice on one side is one box with two wires, as it would
|
||||||
|
be on a sheet; a reference the document cannot resolve gets a dashed placeholder naming
|
||||||
|
the path, because a link the view silently dropped would be the one thing a reader could
|
||||||
|
not find out from here.
|
||||||
|
|
||||||
|
Each gap is sized by `column_gap` to hold a lane for every wire crossing it, and lanes are
|
||||||
|
handed out nearest-target-first, so a wire reaching a distant box turns outside one
|
||||||
|
reaching a near box instead of across it. A dozen links widen the gap rather than
|
||||||
|
crowding a dozen verticals into a 30px band.
|
||||||
|
|
||||||
|
Hovering a wire darkens it and names the link in the footer; clicking a wire or a
|
||||||
|
component follows it. `collect_links` and `sheet_display_name` are pure DOM work, so what
|
||||||
|
the view reads out of a document can be exercised without a window.
|
||||||
|
|
||||||
### Following a link to its other sheet
|
### Following a link to its other sheet
|
||||||
|
|
||||||
Right-clicking a wire or a knob offers **Go to Link Wire Sheet "<name>"** alongside Delete
|
Right-clicking a wire or a knob offers **Go to Link Wire Sheet "<name>"** alongside Delete
|
||||||
@@ -355,21 +410,32 @@ Link, naming the sheet the far end of that link is on -- the end a grey knob sta
|
|||||||
for. The entry is left off a link with both ends on the open sheet, which has nowhere to
|
for. The entry is left off a link with both ends on the open sheet, which has nowhere to
|
||||||
go. `find_component_by_path` in `sax_dom` turns a `<link>`'s from/to reference back into
|
go. `find_component_by_path` in `sax_dom` turns a `<link>`'s from/to reference back into
|
||||||
the component it points at, the inverse of `get_component_path`, and the sheet is that
|
the component it points at, the inverse of `get_component_path`, and the sheet is that
|
||||||
component's parent; a parent of `<app>` is the file's root node, which the popup names
|
component's parent; a parent of `<app>` is the file's root node, which
|
||||||
after the file.
|
`link_view.sheet_display_name` names after the file.
|
||||||
|
|
||||||
The jump selects the same link again once it arrives. It has to be found by its element
|
Clicking a link in the Link View follows it the same way, through the same
|
||||||
rather than by the tag it was picked on, because a repaint hands out fresh wire tags, and
|
`go_to_sheet_with_link`: it opens the sheet of the end that is **not** the component the
|
||||||
it has to wait for that repaint: ttk queues its selection event rather than dispatching
|
view is about, so a link that stays on one sheet goes back to that sheet where both ends
|
||||||
it, so the sheet is not painted at the point the tree node is selected -- and painting a
|
are drawn, and one that crosses opens the other sheet -- the end the view could only show
|
||||||
sheet clears whatever link was selected. The selection is therefore made from an
|
as a knob.
|
||||||
`after_idle` callback, and the link is scrolled to the middle of the view. Arriving is an
|
|
||||||
ordinary sheet opening, so **Back** returns to the sheet the link was picked on.
|
Either way the link is selected again once the sheet arrives. It has to be found by its
|
||||||
|
element rather than by the tag it was picked on, because a repaint hands out fresh wire
|
||||||
|
tags -- and it has to be picked out **after** that repaint, since painting a sheet clears
|
||||||
|
whatever link was selected. An `after_idle` callback is not late enough: ttk dispatches
|
||||||
|
its own selection event later than one of those, so a link selected from an idle callback
|
||||||
|
was wiped by the repaint that followed it. The link is therefore left in
|
||||||
|
`_pending_link_selection` for `on_tree_select` to honour once it has painted, and that
|
||||||
|
handler reads and clears the request first thing -- so a selection that turns out not to
|
||||||
|
paint a sheet drops it rather than leaving it to be applied to some later one. The link is
|
||||||
|
then scrolled to the middle of the view. Arriving is an ordinary sheet opening, so **Back**
|
||||||
|
returns to the sheet the link was followed from.
|
||||||
|
|
||||||
### Testing without a window
|
### Testing without a window
|
||||||
|
|
||||||
`app_state`, `sax_dom`, `schema`, `undo` and `wiresheet` import with no display, so the
|
`app_state`, `sax_dom`, `schema`, `undo` and `wiresheet` import with no display, so the
|
||||||
rules can be exercised directly:
|
rules can be exercised directly. `link_view.collect_links` is pure DOM work too, and reads
|
||||||
|
a component's links without one:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
@@ -500,3 +566,5 @@ And two things worth knowing about the manifests themselves rather than the code
|
|||||||
which is `x1 + radius` rather than `x1`, so snapping is offset by the corner radius.
|
which is `x1 + radius` rather than `x1`, so snapping is offset by the corner radius.
|
||||||
- Slot values are edited from the Properties view only; a wiresheet box row is still
|
- Slot values are edited from the Properties view only; a wiresheet box row is still
|
||||||
display-only.
|
display-only.
|
||||||
|
- The Link View stacks each side in one column, so a component with a dozen peers makes a
|
||||||
|
picture taller than the pane. It scrolls, but it does not wrap into a second column.
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
|||||||
import configparser
|
import configparser
|
||||||
import os
|
import os
|
||||||
|
|
||||||
VERSION = "0.0.0.054"
|
VERSION = "0.0.0.055"
|
||||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||||
DEFAULT_GRID_SIZE = 10
|
DEFAULT_GRID_SIZE = 10
|
||||||
DEFAULT_ADD_TIMESTAMP = True
|
DEFAULT_ADD_TIMESTAMP = True
|
||||||
|
|||||||
+711
@@ -0,0 +1,711 @@
|
|||||||
|
"""The Link View: every link touching one component, drawn around it.
|
||||||
|
|
||||||
|
Like the Properties view it takes the editor pane over from the canvas rather than
|
||||||
|
floating above it, so the sheet and this picture are the same piece of screen and only
|
||||||
|
one of them is ever the thing being looked at. `main` is what hides the canvas and puts
|
||||||
|
this in its place; this module knows only the frame it was handed.
|
||||||
|
|
||||||
|
It answers one question -- "what is wired to this component, and where does it go?" -- and
|
||||||
|
nothing else. Nothing here writes to the document: there is no drag, no add, no delete,
|
||||||
|
so a component's wiring can be read without the risk of rearranging it. The one action is
|
||||||
|
following a link, which hands back to the wiresheet.
|
||||||
|
|
||||||
|
What it draws:
|
||||||
|
|
||||||
|
the chosen component in the middle
|
||||||
|
a component linking into it on the left, feeding its slot
|
||||||
|
a component it links out to on the right, fed from its slot
|
||||||
|
a link on the same wiresheet a solid grey line
|
||||||
|
a link to another wiresheet a dashed grey line, and the grey knob the sheet
|
||||||
|
itself shows on that slot, with the far sheet named
|
||||||
|
|
||||||
|
The pale yellow ground is the one thing that says at a glance that this is not a
|
||||||
|
wiresheet. There is no grid, because nothing here can be positioned: the layout is
|
||||||
|
derived from the links every time it is drawn and no part of it is stored in the file.
|
||||||
|
|
||||||
|
Boxes come from `wiresheet.draw_component_box`, so a component reads exactly as it does
|
||||||
|
on its own sheet -- same header, same slot rows, same values -- and the wire lands on the
|
||||||
|
same slot row a wire would land on there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import font as tkfont, ttk
|
||||||
|
|
||||||
|
import app_state
|
||||||
|
import wiresheet
|
||||||
|
from sax_dom import find_component_by_path, find_parent_element, get_component_path
|
||||||
|
|
||||||
|
# The ground. Pale yellow rather than the sheet's white so a glance at the pane says
|
||||||
|
# which of the two views is on screen, without a caption having to say it.
|
||||||
|
SHEET_BG = "#fbf6d9"
|
||||||
|
|
||||||
|
# Links are grey here whatever they are: this view is not a wiresheet, and the blue of a
|
||||||
|
# real wire would invite the reader to treat it as one. Only the pointer's own hover
|
||||||
|
# darkens a line, which is how it says a line can be clicked.
|
||||||
|
WIRE_COLOUR = "#8a8a8a"
|
||||||
|
WIRE_HOVER_COLOUR = "#3c3c3c"
|
||||||
|
WIRE_WIDTH = 1
|
||||||
|
WIRE_HOVER_WIDTH = 2
|
||||||
|
OFF_SHEET_DASH = (5, 3) # a link whose far end is on another wiresheet
|
||||||
|
|
||||||
|
# The Properties view's header, so the two views wear the same band.
|
||||||
|
HEADER_BG = "#f2f2f2"
|
||||||
|
PATH_COLOUR = "#7a7a7a"
|
||||||
|
|
||||||
|
LABEL_COLOUR = "#6b6b6b" # the "on <sheet>" note under an off-sheet component
|
||||||
|
MISSING_COLOUR = "#b06060" # a link naming a component this document does not have
|
||||||
|
|
||||||
|
# The chosen component wears the same stroke the wiresheet puts on a selected box, so
|
||||||
|
# "this is the one you picked" is said the same way in both views. Its own outline is
|
||||||
|
# restyled rather than a ring being drawn around it: a ring would sit exactly where
|
||||||
|
# every wire meets the box and would nick each arrowhead.
|
||||||
|
FOCUS_OUTLINE = "#1f4e79"
|
||||||
|
FOCUS_OUTLINE_WIDTH = 3
|
||||||
|
|
||||||
|
# Layout, in pixels.
|
||||||
|
MARGIN = 40
|
||||||
|
COLUMN_GAP = 110 # the least room a column of wires is given
|
||||||
|
ROW_GAP = 26
|
||||||
|
STUB = 14 # straight run leaving a slot before the wire may turn
|
||||||
|
LANE_GAP = 9 # between the vertical runs of two wires in one gap
|
||||||
|
SELF_LOOP_DROP = 18 # how far below its box a component's link to itself runs
|
||||||
|
LABEL_GAP = 6 # between a box's bottom and the note under it
|
||||||
|
|
||||||
|
|
||||||
|
def column_gap(wire_count):
|
||||||
|
"""How wide the gap beside a column has to be to hold its wires.
|
||||||
|
|
||||||
|
Every wire crossing a gap turns in a vertical run of its own, and two runs on the
|
||||||
|
same line are one line as far as a reader is concerned -- so the gap is sized to the
|
||||||
|
number of wires rather than fixed, and a component with a dozen links gets a wider
|
||||||
|
gap instead of a dozen wires in a 30px band. COLUMN_GAP is the floor, for the few
|
||||||
|
links that would otherwise be drawn closer together than the boxes they join.
|
||||||
|
"""
|
||||||
|
return max(COLUMN_GAP, 2 * STUB + LANE_GAP * (wire_count + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def sheet_display_name(sheet_element):
|
||||||
|
"""What to call the wiresheet an element sits on.
|
||||||
|
|
||||||
|
A folder is named by itself; the app element has no name of its own and is the row
|
||||||
|
the navigation tree shows under the file's name, so it is named after the file.
|
||||||
|
"""
|
||||||
|
if sheet_element is not None:
|
||||||
|
name = sheet_element.get("name")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
return os.path.basename(app_state.current_file_path or "app")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_links(comp):
|
||||||
|
"""Every link with an end on `comp`, in the order the file lists them.
|
||||||
|
|
||||||
|
Each entry is a dict describing one `<link>` from this component's point of view:
|
||||||
|
|
||||||
|
side 'in' the peer feeds a slot of comp, so it belongs on the left
|
||||||
|
'out' comp feeds a slot of the peer, so it belongs on the right
|
||||||
|
'self' both ends are comp
|
||||||
|
comp_slot the slot on comp; for a self-link, the slot fed
|
||||||
|
peer_slot the slot on the peer; for a self-link, the slot read
|
||||||
|
peer the peer component, or None when the reference names one the
|
||||||
|
document does not have
|
||||||
|
peer_path the path the reference actually names, which is all a broken one has
|
||||||
|
same_sheet whether the peer is a child of the same parent, which is what makes
|
||||||
|
a link drawable as a wire on one sheet
|
||||||
|
|
||||||
|
Pure DOM work, so the reading of a document's links is testable without a window.
|
||||||
|
"""
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
if root is None or comp is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
links_elem = root.find("links")
|
||||||
|
if links_elem is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
comp_path = get_component_path(comp, root)
|
||||||
|
here = find_parent_element(root, comp)
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
for link in links_elem.findall("link"):
|
||||||
|
from_ref = link.get("from", "")
|
||||||
|
to_ref = link.get("to", "")
|
||||||
|
if "." not in from_ref or "." not in to_ref:
|
||||||
|
continue
|
||||||
|
|
||||||
|
from_path, from_slot = from_ref.rsplit(".", 1)
|
||||||
|
to_path, to_slot = to_ref.rsplit(".", 1)
|
||||||
|
|
||||||
|
# A link is read from this component's side: the end that is not comp is the
|
||||||
|
# peer, and which end comp is on is what puts the peer left or right.
|
||||||
|
if from_path == comp_path and to_path == comp_path:
|
||||||
|
side, comp_slot, peer_slot, peer_path = "self", to_slot, from_slot, comp_path
|
||||||
|
elif to_path == comp_path:
|
||||||
|
side, comp_slot, peer_slot, peer_path = "in", to_slot, from_slot, from_path
|
||||||
|
elif from_path == comp_path:
|
||||||
|
side, comp_slot, peer_slot, peer_path = "out", from_slot, to_slot, to_path
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
peer = comp if side == "self" else find_component_by_path(root, peer_path)
|
||||||
|
peer_sheet = find_parent_element(root, peer) if peer is not None else None
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
"link": link,
|
||||||
|
"side": side,
|
||||||
|
"comp_slot": comp_slot,
|
||||||
|
"peer_slot": peer_slot,
|
||||||
|
"peer": peer,
|
||||||
|
"peer_path": peer_path,
|
||||||
|
"peer_sheet": peer_sheet,
|
||||||
|
"same_sheet": peer is not None and peer_sheet is here,
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_peer(entries):
|
||||||
|
"""Collapses the entries of one side onto one box per peer.
|
||||||
|
|
||||||
|
A component wired twice into the same slot block is one box with two wires, exactly
|
||||||
|
as it would be on a sheet -- drawing it twice would say there are two of it.
|
||||||
|
Grouped by the path rather than the element, so a broken reference still groups with
|
||||||
|
itself instead of collapsing every unresolved link into one box.
|
||||||
|
"""
|
||||||
|
groups = []
|
||||||
|
index = {}
|
||||||
|
for entry in entries:
|
||||||
|
key = entry["peer_path"]
|
||||||
|
if key not in index:
|
||||||
|
index[key] = {"peer": entry["peer"], "peer_path": key,
|
||||||
|
"peer_sheet": entry["peer_sheet"],
|
||||||
|
"same_sheet": entry["same_sheet"], "entries": []}
|
||||||
|
groups.append(index[key])
|
||||||
|
index[key]["entries"].append(entry)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
class LinkViewPanel:
|
||||||
|
"""One component's links, drawn around it on a canvas of this view's own.
|
||||||
|
|
||||||
|
Built into a frame the caller supplies and does nothing with it afterwards: packing
|
||||||
|
that frame where the canvas was, and putting the canvas back on Close, is `main`'s
|
||||||
|
business. Nothing here reaches for the wiresheet canvas or the window.
|
||||||
|
|
||||||
|
The picture is drawn once, from the document, and never edited -- so there is no
|
||||||
|
dirty state, nothing to save, and Close asks nothing. `on_open_link` is how following
|
||||||
|
a link gets back to the wiresheet; this module does not know how that is done.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent, comp, on_open_link=None, on_close=None):
|
||||||
|
self.comp = comp
|
||||||
|
self.on_open_link = on_open_link
|
||||||
|
self.on_close = on_close
|
||||||
|
|
||||||
|
# Nothing here can be edited, so there is never anything unwritten. `main` reads
|
||||||
|
# this the way it reads the Properties view's, which is what lets one caller
|
||||||
|
# treat either panel as "the thing in the editor pane".
|
||||||
|
self.dirty = False
|
||||||
|
|
||||||
|
# Read by draw() rather than here, so a redraw after an undo or a delete shows
|
||||||
|
# the links the document has now rather than the ones it had when this opened.
|
||||||
|
self.entries = []
|
||||||
|
|
||||||
|
# tag -> the link that tag stands for, and what a hover has to put back. Rebuilt
|
||||||
|
# by every draw, since a repaint hands out fresh tags.
|
||||||
|
self.link_by_tag = {}
|
||||||
|
self.hover_restore = {}
|
||||||
|
|
||||||
|
self.frame = tk.Frame(parent)
|
||||||
|
|
||||||
|
# The footer is packed first and against the bottom: pack hands out leftover
|
||||||
|
# space in order, so an expanding area packed first leaves a later sibling none.
|
||||||
|
self._build_header()
|
||||||
|
self._build_footer()
|
||||||
|
self._build_canvas_area()
|
||||||
|
self.draw()
|
||||||
|
|
||||||
|
# -- construction --
|
||||||
|
|
||||||
|
def _build_header(self):
|
||||||
|
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
|
||||||
|
header.pack(fill=tk.X)
|
||||||
|
|
||||||
|
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
||||||
|
plain_font = tkfont.Font(family="Segoe UI", size=9)
|
||||||
|
|
||||||
|
tk.Label(header, text=self.comp.get("name", "?"), font=name_font,
|
||||||
|
bg=HEADER_BG).pack(side=tk.LEFT)
|
||||||
|
tk.Label(header, text=f"({self.comp.get('type', 'sys::Component')})",
|
||||||
|
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
|
||||||
|
padx=(8, 0))
|
||||||
|
|
||||||
|
# The path is what a <link> names this component by, so it is the one thing on
|
||||||
|
# screen that says which of four identically named boxes this picture is about.
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
path = get_component_path(self.comp, root) if root is not None else ""
|
||||||
|
tk.Label(header, text=path, font=plain_font, fg=PATH_COLOUR,
|
||||||
|
bg=HEADER_BG).pack(side=tk.LEFT, padx=(16, 0))
|
||||||
|
|
||||||
|
# Filled in by draw(), which is what counts the links.
|
||||||
|
self.count_var = tk.StringVar(value="")
|
||||||
|
tk.Label(header, textvariable=self.count_var, font=plain_font, fg="#555555",
|
||||||
|
bg=HEADER_BG).pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
def _build_canvas_area(self):
|
||||||
|
area = tk.Frame(self.frame)
|
||||||
|
area.pack(fill=tk.BOTH, expand=True)
|
||||||
|
area.grid_rowconfigure(0, weight=1)
|
||||||
|
area.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.canvas = tk.Canvas(area, bg=SHEET_BG, highlightthickness=0)
|
||||||
|
self.canvas.grid(row=0, column=0, sticky="nsew")
|
||||||
|
|
||||||
|
self.vertical_bar = ttk.Scrollbar(area, orient="vertical",
|
||||||
|
command=self.canvas.yview)
|
||||||
|
self.vertical_bar.grid(row=0, column=1, sticky="ns")
|
||||||
|
|
||||||
|
self.horizontal_bar = ttk.Scrollbar(area, orient="horizontal",
|
||||||
|
command=self.canvas.xview)
|
||||||
|
self.horizontal_bar.grid(row=1, column=0, sticky="ew")
|
||||||
|
|
||||||
|
# Each bar is shown only while it has somewhere to scroll, so a pane wide enough
|
||||||
|
# for the picture carries neither.
|
||||||
|
self.canvas.configure(
|
||||||
|
yscrollcommand=lambda first, last: self._sync_bar(
|
||||||
|
self.vertical_bar, first, last),
|
||||||
|
xscrollcommand=lambda first, last: self._sync_bar(
|
||||||
|
self.horizontal_bar, first, last),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bound on this frame, not bind_all: the wiresheet canvas has wheel bindings of
|
||||||
|
# its own, and an application-wide one here would scroll this from there.
|
||||||
|
self.frame.bind("<MouseWheel>", self._on_wheel)
|
||||||
|
self.frame.bind("<Shift-MouseWheel>", self._on_shift_wheel)
|
||||||
|
|
||||||
|
def _build_footer(self):
|
||||||
|
footer = tk.Frame(self.frame, padx=8, pady=6)
|
||||||
|
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
||||||
|
|
||||||
|
# A legend, because the difference between the two line styles is the whole
|
||||||
|
# point of the picture and is not self-evident from one line.
|
||||||
|
legend = tk.Canvas(footer, width=210, height=16, highlightthickness=0,
|
||||||
|
bg=footer.cget("bg"))
|
||||||
|
legend.pack(side=tk.LEFT, padx=(0, 10))
|
||||||
|
legend.create_line(2, 8, 26, 8, fill=WIRE_COLOUR, width=WIRE_WIDTH)
|
||||||
|
legend.create_text(30, 8, anchor="w", text="this sheet", fill=LABEL_COLOUR,
|
||||||
|
font=("Segoe UI", 8))
|
||||||
|
legend.create_line(96, 8, 120, 8, fill=WIRE_COLOUR, width=WIRE_WIDTH,
|
||||||
|
dash=OFF_SHEET_DASH)
|
||||||
|
legend.create_text(124, 8, anchor="w", text="another sheet", fill=LABEL_COLOUR,
|
||||||
|
font=("Segoe UI", 8))
|
||||||
|
|
||||||
|
self.status_var = tk.StringVar(value="")
|
||||||
|
tk.Label(footer, textvariable=self.status_var, anchor="w",
|
||||||
|
fg="#333333").pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||||
|
|
||||||
|
ttk.Button(footer, text="Close", width=9,
|
||||||
|
command=self.close).pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
def _sync_bar(self, bar, first, last):
|
||||||
|
"""Moves a scrollbar's thumb, and hides the bar when it spans the whole."""
|
||||||
|
bar.set(first, last)
|
||||||
|
if float(first) <= 0.0 and float(last) >= 1.0:
|
||||||
|
bar.grid_remove()
|
||||||
|
else:
|
||||||
|
bar.grid()
|
||||||
|
|
||||||
|
def _on_wheel(self, event):
|
||||||
|
if self.canvas.winfo_exists():
|
||||||
|
self.canvas.yview_scroll(-1 * (event.delta // 120), "units")
|
||||||
|
|
||||||
|
def _on_shift_wheel(self, event):
|
||||||
|
if self.canvas.winfo_exists():
|
||||||
|
self.canvas.xview_scroll(-1 * (event.delta // 120), "units")
|
||||||
|
|
||||||
|
# -- drawing --
|
||||||
|
|
||||||
|
def draw(self):
|
||||||
|
"""Lays the picture out from the links and paints it.
|
||||||
|
|
||||||
|
Positions are worked out here rather than read from the file: `meta` carries a
|
||||||
|
component's place on its own wiresheet, which says nothing about where it
|
||||||
|
belongs relative to this one. Both columns are measured before anything is
|
||||||
|
drawn, because the middle box's position depends on the taller of them.
|
||||||
|
"""
|
||||||
|
canvas = self.canvas
|
||||||
|
canvas.delete("all")
|
||||||
|
self.link_by_tag.clear()
|
||||||
|
self.hover_restore.clear()
|
||||||
|
|
||||||
|
self.entries = collect_links(self.comp)
|
||||||
|
count = len(self.entries)
|
||||||
|
self.count_var.set(f"{count} link{'' if count == 1 else 's'}")
|
||||||
|
|
||||||
|
fonts = wiresheet.sheet_fonts()
|
||||||
|
# The same index the sheet paints with, so a linked slot has a row here too and
|
||||||
|
# a wire has the same row to land on.
|
||||||
|
linked_slots = wiresheet.build_linked_slot_index()
|
||||||
|
|
||||||
|
incoming = group_by_peer([e for e in self.entries if e["side"] == "in"])
|
||||||
|
outgoing = group_by_peer([e for e in self.entries if e["side"] == "out"])
|
||||||
|
self_links = [e for e in self.entries if e["side"] == "self"]
|
||||||
|
|
||||||
|
focus_size = wiresheet.measure_box(self.comp, fonts, linked_slots)[:2]
|
||||||
|
left_sizes = [self._group_size(group, fonts, linked_slots) for group in incoming]
|
||||||
|
right_sizes = [self._group_size(group, fonts, linked_slots) for group in outgoing]
|
||||||
|
|
||||||
|
left_width = max([size[0] for size in left_sizes], default=0)
|
||||||
|
right_width = max([size[0] for size in right_sizes], default=0)
|
||||||
|
left_height = self._column_height(left_sizes)
|
||||||
|
right_height = self._column_height(right_sizes)
|
||||||
|
|
||||||
|
content_height = max(left_height, right_height, focus_size[1])
|
||||||
|
|
||||||
|
# A link a component makes to itself loops out through both gaps, so it is
|
||||||
|
# counted in each of them.
|
||||||
|
left_gap = column_gap(sum(1 for e in self.entries if e["side"] != "out"))
|
||||||
|
right_gap = column_gap(sum(1 for e in self.entries if e["side"] != "in"))
|
||||||
|
|
||||||
|
focus_x = MARGIN + (left_width + left_gap if incoming else 0)
|
||||||
|
right_x = focus_x + focus_size[0] + right_gap
|
||||||
|
focus_y = MARGIN + (content_height - focus_size[1]) / 2
|
||||||
|
|
||||||
|
focus_box = self._draw_box(self.comp, focus_x, focus_y, fonts, linked_slots,
|
||||||
|
("lv_focus",), outline_tags=("lv_focus_outline",))
|
||||||
|
canvas.itemconfig("lv_focus_outline", outline=FOCUS_OUTLINE,
|
||||||
|
width=FOCUS_OUTLINE_WIDTH)
|
||||||
|
|
||||||
|
# Every box is placed before any wire is drawn, so a box can never be laid over
|
||||||
|
# a wire that was already there.
|
||||||
|
jobs = {"in": [], "out": []}
|
||||||
|
columns = ((incoming, left_sizes, left_height, left_width, MARGIN, "in"),
|
||||||
|
(outgoing, right_sizes, right_height, right_width, right_x, "out"))
|
||||||
|
for column, sizes, column_height, column_width, column_x, side in columns:
|
||||||
|
y = MARGIN + (content_height - column_height) / 2
|
||||||
|
for group, (width, height) in zip(column, sizes):
|
||||||
|
# Boxes are right-aligned on the left and left-aligned on the right, so
|
||||||
|
# every wire crosses the same gap however wide its own box turned out.
|
||||||
|
x = column_x + (column_width - width) if side == "in" else column_x
|
||||||
|
box = self._draw_group_box(group, x, y, fonts, linked_slots)
|
||||||
|
for entry in group["entries"]:
|
||||||
|
jobs[side].append((entry, box))
|
||||||
|
y += height + ROW_GAP
|
||||||
|
|
||||||
|
# Lanes are handed out nearest-first, so a wire reaching a distant box turns
|
||||||
|
# outside one reaching a near box rather than across it. Both ends of a link on
|
||||||
|
# this side are known by now, which is what lets the order be worked out at all.
|
||||||
|
for side, side_jobs in jobs.items():
|
||||||
|
side_jobs.sort(key=lambda job: abs(
|
||||||
|
self._slot_y(job[1], job[0]["peer_slot"])
|
||||||
|
- self._slot_y(focus_box, job[0]["comp_slot"])))
|
||||||
|
for rank, (entry, box) in enumerate(side_jobs):
|
||||||
|
self._draw_wire(entry, side, focus_box, box, rank)
|
||||||
|
|
||||||
|
# A self-link crosses both gaps, so it takes the next lane out on each side.
|
||||||
|
for offset, entry in enumerate(self_links):
|
||||||
|
self._draw_self_wire(entry, focus_box,
|
||||||
|
len(jobs["in"]) + offset,
|
||||||
|
len(jobs["out"]) + offset)
|
||||||
|
|
||||||
|
# Wires sit behind the boxes, as they do on a wiresheet, so a run that has to
|
||||||
|
# pass a component -- a link a component makes to itself, at the far end of its
|
||||||
|
# loop -- can never be mistaken for something drawn on top of that box. Knobs
|
||||||
|
# are not lowered with them: a knob marks a slot and belongs on top of it.
|
||||||
|
self.canvas.tag_lower("lv_wire")
|
||||||
|
|
||||||
|
if not self.entries:
|
||||||
|
canvas.create_text(
|
||||||
|
focus_x + focus_size[0] / 2, focus_y + focus_size[1] + 24,
|
||||||
|
text="Nothing is linked to this component.",
|
||||||
|
fill=LABEL_COLOUR, font=("Segoe UI", 9)
|
||||||
|
)
|
||||||
|
self.status_var.set("No links touch this component.")
|
||||||
|
else:
|
||||||
|
# An unresolved reference is not on another wiresheet -- it is on no sheet at
|
||||||
|
# all -- so the two are counted apart rather than read as the same thing.
|
||||||
|
off_sheet = sum(1 for e in self.entries
|
||||||
|
if e["peer"] is not None and not e["same_sheet"])
|
||||||
|
broken = sum(1 for e in self.entries if e["peer"] is None)
|
||||||
|
note = f"{count} link{'' if count == 1 else 's'}"
|
||||||
|
if off_sheet:
|
||||||
|
note += f", {off_sheet} on another wiresheet"
|
||||||
|
if broken:
|
||||||
|
note += f", {broken} naming a component this document does not have"
|
||||||
|
self.status_var.set(f"{note}. Click a link or a component to go to it.")
|
||||||
|
|
||||||
|
self._fit_scrollregion()
|
||||||
|
|
||||||
|
def _group_size(self, group, fonts, linked_slots):
|
||||||
|
"""How much room one peer box takes, note included."""
|
||||||
|
if group["peer"] is None:
|
||||||
|
return self._missing_box_size(group["peer_path"])
|
||||||
|
width, height = wiresheet.measure_box(group["peer"], fonts, linked_slots)[:2]
|
||||||
|
if not group["same_sheet"]:
|
||||||
|
# The "on <sheet>" note sits under the box and is part of what the row
|
||||||
|
# has to clear, or the next box would be drawn over it.
|
||||||
|
height += LABEL_GAP + 14
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
def _missing_box_size(self, peer_path):
|
||||||
|
note_font = tkfont.Font(family="Segoe UI", size=8)
|
||||||
|
return max(120, note_font.measure(peer_path) + 20), 34
|
||||||
|
|
||||||
|
def _column_height(self, sizes):
|
||||||
|
if not sizes:
|
||||||
|
return 0
|
||||||
|
return sum(size[1] for size in sizes) + ROW_GAP * (len(sizes) - 1)
|
||||||
|
|
||||||
|
def _draw_box(self, comp, x, y, fonts, linked_slots, tags, outline_tags=()):
|
||||||
|
"""Paints a component box and returns what a wire needs to reach its slots."""
|
||||||
|
width, height, slot_offsets = wiresheet.draw_component_box(
|
||||||
|
self.canvas, comp, x, y, fonts, linked_slots, tags, outline_tags
|
||||||
|
)
|
||||||
|
return {"x": x, "y": y, "width": width, "height": height,
|
||||||
|
"slots": slot_offsets}
|
||||||
|
|
||||||
|
def _draw_group_box(self, group, x, y, fonts, linked_slots):
|
||||||
|
"""One peer's box, its sheet named under it when that sheet is not this one.
|
||||||
|
|
||||||
|
The box carries the group's own tag so clicking it follows the first of its
|
||||||
|
links, which is the same thing clicking that wire does. A reference the document
|
||||||
|
cannot resolve gets a placeholder instead: there is no component to draw, and
|
||||||
|
drawing nothing would lose the link entirely.
|
||||||
|
"""
|
||||||
|
tag = f"lv_peer_{id(group)}"
|
||||||
|
entries = group["entries"]
|
||||||
|
|
||||||
|
if group["peer"] is None:
|
||||||
|
width, height = self._missing_box_size(group["peer_path"])
|
||||||
|
self.canvas.create_rectangle(
|
||||||
|
x, y, x + width, y + height, fill="#f4eccd", outline=MISSING_COLOUR,
|
||||||
|
dash=OFF_SHEET_DASH, tags=(tag,)
|
||||||
|
)
|
||||||
|
self.canvas.create_text(
|
||||||
|
x + width / 2, y + 12, text=group["peer_path"], fill=MISSING_COLOUR,
|
||||||
|
font=("Segoe UI", 8), tags=(tag,)
|
||||||
|
)
|
||||||
|
self.canvas.create_text(
|
||||||
|
x + width / 2, y + 24, text="not in this document",
|
||||||
|
fill=MISSING_COLOUR, font=("Segoe UI", 8), tags=(tag,)
|
||||||
|
)
|
||||||
|
box = {"x": x, "y": y, "width": width, "height": height, "slots": {}}
|
||||||
|
else:
|
||||||
|
box = self._draw_box(group["peer"], x, y, fonts, linked_slots, (tag,))
|
||||||
|
if not group["same_sheet"]:
|
||||||
|
self.canvas.create_text(
|
||||||
|
x + box["width"] / 2, y + box["height"] + LABEL_GAP,
|
||||||
|
anchor="n", text=f"on {sheet_display_name(group['peer_sheet'])}",
|
||||||
|
fill=LABEL_COLOUR, font=("Segoe UI", 8), tags=(tag,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# A box stands for its links, so clicking it follows one. Registered against the
|
||||||
|
# first, and the status line says so when there is more than one to choose from.
|
||||||
|
self._make_followable(tag, entries[0]["link"], len(entries))
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _slot_y(self, box, slot_name):
|
||||||
|
"""Where a wire meets a box: its slot's row, or the box's middle if it has none."""
|
||||||
|
offset = box["slots"].get(slot_name)
|
||||||
|
if offset is None:
|
||||||
|
return box["y"] + box["height"] / 2
|
||||||
|
return box["y"] + offset
|
||||||
|
|
||||||
|
def _lane_x(self, focus_box, side, rank):
|
||||||
|
"""Where a wire's vertical run stands, measured out from the chosen component.
|
||||||
|
|
||||||
|
Out from the focus rather than in from the peer, because every wire on one side
|
||||||
|
leaves the same box edge while the boxes it reaches are all different heights:
|
||||||
|
anchoring to the shared edge is what makes the fan nest instead of tangle.
|
||||||
|
`column_gap` sized the gap to hold every rank, so a lane always lands inside it.
|
||||||
|
"""
|
||||||
|
if side == "in":
|
||||||
|
return focus_box["x"] - STUB - (rank + 1) * LANE_GAP
|
||||||
|
return focus_box["x"] + focus_box["width"] + STUB + (rank + 1) * LANE_GAP
|
||||||
|
|
||||||
|
def _draw_wire(self, entry, side, focus_box, peer_box, rank):
|
||||||
|
"""One link, from the box that feeds it to the box that reads it.
|
||||||
|
|
||||||
|
Drawn peer-to-focus on the left and focus-to-peer on the right, so every arrow
|
||||||
|
points the way the signal travels and the picture reads left to right the way a
|
||||||
|
wiresheet does. A link whose far end is on another sheet is dashed, and keeps
|
||||||
|
the grey knob its own sheet paints on that slot -- so the slot looks the same
|
||||||
|
here as it does there, with the far component drawn in as well.
|
||||||
|
"""
|
||||||
|
focus_y = self._slot_y(focus_box, entry["comp_slot"])
|
||||||
|
peer_y = self._slot_y(peer_box, entry["peer_slot"])
|
||||||
|
off_sheet = not entry["same_sheet"]
|
||||||
|
|
||||||
|
if side == "in":
|
||||||
|
start = (peer_box["x"] + peer_box["width"], peer_y)
|
||||||
|
end_x = focus_box["x"]
|
||||||
|
knob_x = end_x - wiresheet.KNOB_RADIUS
|
||||||
|
end = (end_x - 2 * wiresheet.KNOB_RADIUS if off_sheet else end_x, focus_y)
|
||||||
|
else:
|
||||||
|
start = (focus_box["x"] + focus_box["width"], focus_y)
|
||||||
|
knob_x = start[0] + wiresheet.KNOB_RADIUS
|
||||||
|
if off_sheet:
|
||||||
|
start = (start[0] + 2 * wiresheet.KNOB_RADIUS, focus_y)
|
||||||
|
end = (peer_box["x"], peer_y)
|
||||||
|
|
||||||
|
tag = f"lv_link_{id(entry['link'])}_{side}_{rank}"
|
||||||
|
self._draw_route(
|
||||||
|
self._route(start, end, self._lane_x(focus_box, side, rank)),
|
||||||
|
tag, off_sheet
|
||||||
|
)
|
||||||
|
|
||||||
|
if off_sheet:
|
||||||
|
# The knob belongs to the slot, not to the wire, so it is drawn outside the
|
||||||
|
# wire's tag: hovering the line must not recolour the slot's marker.
|
||||||
|
wiresheet.draw_knob(self.canvas, knob_x, focus_y, False, True)
|
||||||
|
|
||||||
|
self._make_followable(tag, entry["link"], 1)
|
||||||
|
|
||||||
|
def _route(self, start, end, lane_x):
|
||||||
|
"""A wire's corners: out of one slot, across the gap, into the other.
|
||||||
|
|
||||||
|
Three segments rather than a diagonal, because that is how the wiresheet draws
|
||||||
|
one and a picture of the same links should not change shape between the two.
|
||||||
|
Two slots already at the same height need no turn at all.
|
||||||
|
"""
|
||||||
|
(sx, sy), (ex, ey) = start, end
|
||||||
|
if abs(sy - ey) < 0.5:
|
||||||
|
return [(sx, sy), (ex, ey)]
|
||||||
|
return [(sx, sy), (lane_x, sy), (lane_x, ey), (ex, ey)]
|
||||||
|
|
||||||
|
def _draw_self_wire(self, entry, focus_box, in_rank, out_rank):
|
||||||
|
"""A component linked to itself: out of one of its slots and back into another.
|
||||||
|
|
||||||
|
It has no peer to stand opposite, so the wire loops under the box, using the
|
||||||
|
next lane out on each side so it clears every wire that has a peer. Rare, and
|
||||||
|
drawn rather than dropped because a link the view did not show would be the one
|
||||||
|
thing a reader could not find out from here.
|
||||||
|
"""
|
||||||
|
out_y = self._slot_y(focus_box, entry["peer_slot"])
|
||||||
|
in_y = self._slot_y(focus_box, entry["comp_slot"])
|
||||||
|
right_lane = self._lane_x(focus_box, "out", out_rank)
|
||||||
|
left_lane = self._lane_x(focus_box, "in", in_rank)
|
||||||
|
below = (focus_box["y"] + focus_box["height"] + SELF_LOOP_DROP
|
||||||
|
+ out_rank * LANE_GAP)
|
||||||
|
|
||||||
|
points = [(focus_box["x"] + focus_box["width"], out_y), (right_lane, out_y),
|
||||||
|
(right_lane, below), (left_lane, below),
|
||||||
|
(left_lane, in_y), (focus_box["x"], in_y)]
|
||||||
|
|
||||||
|
tag = f"lv_link_{id(entry['link'])}_self_{out_rank}"
|
||||||
|
self._draw_route(points, tag, not entry["same_sheet"])
|
||||||
|
self._make_followable(tag, entry["link"], 1)
|
||||||
|
|
||||||
|
def _draw_route(self, points, tag, off_sheet):
|
||||||
|
flat = [coord for point in points for coord in point]
|
||||||
|
self.canvas.create_line(
|
||||||
|
*flat, fill=WIRE_COLOUR, width=WIRE_WIDTH, arrow=tk.LAST,
|
||||||
|
arrowshape=(8, 9, 3), dash=OFF_SHEET_DASH if off_sheet else None,
|
||||||
|
tags=("lv_wire", tag)
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- following a link --
|
||||||
|
|
||||||
|
def _make_followable(self, tag, link_elem, link_count):
|
||||||
|
"""Makes everything under one tag say where it goes, and go there when clicked.
|
||||||
|
|
||||||
|
Bound per tag rather than per item, so a box's dozen items behave as the one
|
||||||
|
thing they draw.
|
||||||
|
"""
|
||||||
|
self.link_by_tag[tag] = (link_elem, link_count)
|
||||||
|
self.canvas.tag_bind(tag, "<Enter>", lambda _e, t=tag: self._on_enter(t))
|
||||||
|
self.canvas.tag_bind(tag, "<Leave>", lambda _e, t=tag: self._on_leave(t))
|
||||||
|
self.canvas.tag_bind(tag, "<Button-1>", lambda _e, t=tag: self._on_click(t))
|
||||||
|
|
||||||
|
def _on_enter(self, tag):
|
||||||
|
link_elem, link_count = self.link_by_tag[tag]
|
||||||
|
self.canvas.configure(cursor="hand2")
|
||||||
|
|
||||||
|
# Only the wire is recoloured; a box under the pointer keeps its own colours, so
|
||||||
|
# the component still reads as the component it is.
|
||||||
|
for item in self.canvas.find_withtag(tag):
|
||||||
|
if self.canvas.type(item) != "line" or "lv_wire" not in self.canvas.gettags(item):
|
||||||
|
continue
|
||||||
|
self.hover_restore[item] = (self.canvas.itemcget(item, "fill"),
|
||||||
|
self.canvas.itemcget(item, "width"))
|
||||||
|
self.canvas.itemconfig(item, fill=WIRE_HOVER_COLOUR, width=WIRE_HOVER_WIDTH)
|
||||||
|
|
||||||
|
message = f"{link_elem.get('from', '?')} → {link_elem.get('to', '?')}"
|
||||||
|
if link_count > 1:
|
||||||
|
message += f" (first of {link_count} links to this component)"
|
||||||
|
self.status_var.set(message)
|
||||||
|
|
||||||
|
def _on_leave(self, tag):
|
||||||
|
self.canvas.configure(cursor="")
|
||||||
|
for item in list(self.hover_restore):
|
||||||
|
fill, width = self.hover_restore.pop(item)
|
||||||
|
try:
|
||||||
|
self.canvas.itemconfig(item, fill=fill, width=width)
|
||||||
|
except tk.TclError:
|
||||||
|
pass # the item went away with a redraw
|
||||||
|
self.status_var.set("")
|
||||||
|
|
||||||
|
def _on_click(self, tag):
|
||||||
|
link_elem = self.link_by_tag[tag][0]
|
||||||
|
if self.on_open_link is not None:
|
||||||
|
self.on_open_link(link_elem)
|
||||||
|
|
||||||
|
# -- leaving --
|
||||||
|
|
||||||
|
def _fit_scrollregion(self):
|
||||||
|
"""Sizes the canvas to the picture and puts the chosen component in view.
|
||||||
|
|
||||||
|
The region is the drawn extent with a margin, rather than a fixed span: this
|
||||||
|
picture is only ever as big as the links make it.
|
||||||
|
"""
|
||||||
|
bounds = self.canvas.bbox("all")
|
||||||
|
if bounds is None:
|
||||||
|
self.canvas.configure(scrollregion=(0, 0, 1, 1))
|
||||||
|
return
|
||||||
|
|
||||||
|
region = (min(0, bounds[0] - MARGIN), min(0, bounds[1] - MARGIN),
|
||||||
|
bounds[2] + MARGIN, bounds[3] + MARGIN)
|
||||||
|
self.canvas.configure(scrollregion=region)
|
||||||
|
# A picture wider or taller than the pane opens on the middle box, which is the
|
||||||
|
# one thing the view is about.
|
||||||
|
self.canvas.after_idle(self._centre_on_focus)
|
||||||
|
|
||||||
|
def _centre_on_focus(self):
|
||||||
|
if not self.canvas.winfo_exists():
|
||||||
|
return
|
||||||
|
box = self.canvas.bbox("lv_focus")
|
||||||
|
region = str(self.canvas.cget("scrollregion")).split()
|
||||||
|
if box is None or len(region) != 4:
|
||||||
|
return
|
||||||
|
|
||||||
|
left, top, right, bottom = (float(value) for value in region)
|
||||||
|
span_x = (right - left) or 1.0
|
||||||
|
span_y = (bottom - top) or 1.0
|
||||||
|
middle_x = (box[0] + box[2]) / 2 - self.canvas.winfo_width() / 2
|
||||||
|
middle_y = (box[1] + box[3]) / 2 - self.canvas.winfo_height() / 2
|
||||||
|
self.canvas.xview_moveto(max(0.0, (middle_x - left) / span_x))
|
||||||
|
self.canvas.yview_moveto(max(0.0, (middle_y - top) / span_y))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Hands the pane back. Nothing to ask: this view has never changed anything."""
|
||||||
|
if self.on_close is not None:
|
||||||
|
self.on_close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
"""Tears the panel's widgets down, for a caller that is done with it."""
|
||||||
|
if self.frame.winfo_exists():
|
||||||
|
self.frame.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def build_link_view_panel(parent, comp, on_open_link=None, on_close=None):
|
||||||
|
"""A Link View for one component, built into `parent` but not yet placed.
|
||||||
|
|
||||||
|
`on_open_link` is handed the `<link>` element a click chose, and is what takes the
|
||||||
|
view down and opens the wiresheet it leads to; `on_close` is what the Close button
|
||||||
|
asks for. The caller packs `panel.frame` wherever it wants it.
|
||||||
|
"""
|
||||||
|
if comp is None or parent is None:
|
||||||
|
return None
|
||||||
|
return LinkViewPanel(parent, comp, on_open_link, on_close)
|
||||||
@@ -6,6 +6,7 @@ import tkinter as tk
|
|||||||
from tkinter import filedialog, font as tkfont, messagebox, ttk
|
from tkinter import filedialog, font as tkfont, messagebox, ttk
|
||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
|
import link_view
|
||||||
import manage_kits
|
import manage_kits
|
||||||
import palette
|
import palette
|
||||||
import properties
|
import properties
|
||||||
@@ -62,17 +63,25 @@ zoom_label = None
|
|||||||
menu_view = None
|
menu_view = None
|
||||||
show_left_column_var = None
|
show_left_column_var = None
|
||||||
|
|
||||||
# The Properties view takes the editor pane over from the canvas rather than floating
|
# The Properties and Link views each take the editor pane over from the canvas rather
|
||||||
# above it, so at most one of them is on screen and this is which. None means the sheet.
|
# than floating above it, so at most one of the three is on screen and these two say
|
||||||
|
# which. Both None means the sheet is showing.
|
||||||
properties_panel = None
|
properties_panel = None
|
||||||
|
link_panel = None
|
||||||
|
|
||||||
# The sheet to put the tree back on when a sheet change is refused because of unsaved
|
# The sheet to put the tree back on when a sheet change is refused because of unsaved
|
||||||
# edits, and the flag that stops the restoring selection from being read as a fresh
|
# edits, and the flag that stops the restoring selection from being read as a fresh
|
||||||
# choice. The element is held rather than the tree item id, for the same reason the
|
# choice. The element is held rather than the tree item id, for the same reason the
|
||||||
# history holds elements: a rebuild of the tree hands out fresh ids.
|
# history holds elements: a rebuild of the tree hands out fresh ids.
|
||||||
properties_return_sheet = None
|
panel_return_sheet = None
|
||||||
_restoring_selection = False
|
_restoring_selection = False
|
||||||
|
|
||||||
|
# The link a jump has asked to be picked out, held until the sheet it is on has been
|
||||||
|
# painted. Selecting a tree node paints its sheet, and painting a sheet clears whatever
|
||||||
|
# link was selected, so the pick cannot be made at the point the node is selected --
|
||||||
|
# see go_to_sheet_with_link.
|
||||||
|
_pending_link_selection = None
|
||||||
|
|
||||||
|
|
||||||
def anything_unwritten():
|
def anything_unwritten():
|
||||||
"""Whether there is a change no one has written yet, in the document or in a field.
|
"""Whether there is a change no one has written yet, in the document or in a field.
|
||||||
@@ -113,10 +122,18 @@ def refresh_all_views():
|
|||||||
sax_file.refresh_treeview_from_dom(app_state.xml_root_element)
|
sax_file.refresh_treeview_from_dom(app_state.xml_root_element)
|
||||||
wiresheet.render_current_sheet()
|
wiresheet.render_current_sheet()
|
||||||
|
|
||||||
# An undo or a cascading delete can take the component the Properties view is showing
|
# An undo or a cascading delete can take the component a panel is showing out of the
|
||||||
# out of the document underneath it. Nothing it could display then would be true.
|
# document underneath it. Nothing it could display then would be true.
|
||||||
if properties_panel is not None and not _component_in_document(properties_panel.comp):
|
if properties_panel is not None and not _component_in_document(properties_panel.comp):
|
||||||
close_properties(force=True)
|
close_editor_panel(force=True)
|
||||||
|
|
||||||
|
# The Link View reads the document and writes nothing, so a change elsewhere is
|
||||||
|
# simply redrawn rather than being a reason to take the view down.
|
||||||
|
if link_panel is not None:
|
||||||
|
if _component_in_document(link_panel.comp):
|
||||||
|
link_panel.draw()
|
||||||
|
else:
|
||||||
|
close_editor_panel(force=True)
|
||||||
|
|
||||||
|
|
||||||
def _component_in_document(comp):
|
def _component_in_document(comp):
|
||||||
@@ -125,40 +142,63 @@ def _component_in_document(comp):
|
|||||||
|
|
||||||
|
|
||||||
def delete_component(comp):
|
def delete_component(comp):
|
||||||
"""Deletes a component, taking its Properties view down with it if one is showing.
|
"""Deletes a component, taking a view of it down with it if one is showing.
|
||||||
|
|
||||||
Every delete in the UI comes through here. sax_file does its own view refresh rather
|
Every delete in the UI comes through here. sax_file does its own view refresh rather
|
||||||
than going through refresh_all_views, so the panel would otherwise be left describing
|
than going through refresh_all_views, so the panel would otherwise be left describing
|
||||||
a component the document no longer has.
|
a component the document no longer has.
|
||||||
"""
|
"""
|
||||||
showing = properties_panel is not None and properties_panel.comp is comp
|
panel = properties_panel or link_panel
|
||||||
|
showing = panel is not None and panel.comp is comp
|
||||||
if sax_file.execute_cascading_deletion(comp) and showing:
|
if sax_file.execute_cascading_deletion(comp) and showing:
|
||||||
close_properties(force=True)
|
close_editor_panel(force=True)
|
||||||
|
|
||||||
|
|
||||||
def show_properties(comp):
|
def editor_panel_showing():
|
||||||
"""Puts one component's slots in the editor pane, in place of the wiresheet.
|
"""Whether something other than the wiresheet has the editor pane."""
|
||||||
|
return properties_panel is not None or link_panel is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _take_editor_pane():
|
||||||
|
"""Hides the sheet so a panel can have the pane.
|
||||||
|
|
||||||
The canvas is only unpacked, never destroyed, so the sheet keeps its items and its
|
The canvas is only unpacked, never destroyed, so the sheet keeps its items and its
|
||||||
scroll position and comes back exactly as it was. It is still painted while it is
|
scroll position and comes back exactly as it was. It is still painted while it is
|
||||||
hidden -- a Save here repaints it -- which costs nothing and means there is no
|
hidden -- a Properties Save repaints it -- which costs nothing and means there is no
|
||||||
catching up to do when it returns.
|
catching up to do when it returns.
|
||||||
"""
|
"""
|
||||||
global properties_panel, properties_return_sheet
|
global panel_return_sheet
|
||||||
if comp is None:
|
panel_return_sheet = app_state.current_sheet_parent
|
||||||
return
|
|
||||||
|
|
||||||
# Right-clicking a second component is a way of leaving the first, so it asks about
|
|
||||||
# unsaved edits exactly as Close does, and a refusal leaves the first one showing.
|
|
||||||
if not close_properties():
|
|
||||||
return
|
|
||||||
|
|
||||||
properties_return_sheet = app_state.current_sheet_parent
|
|
||||||
|
|
||||||
canvas_vscroll.pack_forget()
|
canvas_vscroll.pack_forget()
|
||||||
canvas_hscroll.pack_forget()
|
canvas_hscroll.pack_forget()
|
||||||
app_state.canvas.pack_forget()
|
app_state.canvas.pack_forget()
|
||||||
|
|
||||||
|
|
||||||
|
def _give_editor_pane_back():
|
||||||
|
"""Puts the sheet back, in the order the pane was built in.
|
||||||
|
|
||||||
|
The scrollbars are repacked before the canvas because pack honours the order it was
|
||||||
|
given: packed after it, they would take their space from the middle of the pane.
|
||||||
|
"""
|
||||||
|
canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||||
|
canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
||||||
|
app_state.canvas.pack(fill=tk.BOTH, expand=True)
|
||||||
|
app_state.clear_status()
|
||||||
|
|
||||||
|
|
||||||
|
def show_properties(comp):
|
||||||
|
"""Puts one component's slots in the editor pane, in place of the wiresheet."""
|
||||||
|
global properties_panel
|
||||||
|
if comp is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Right-clicking a second component is a way of leaving the first, so it asks about
|
||||||
|
# unsaved edits exactly as Close does, and a refusal leaves the first one showing.
|
||||||
|
if not close_editor_panel():
|
||||||
|
return
|
||||||
|
|
||||||
|
_take_editor_pane()
|
||||||
properties_panel = properties.build_properties_panel(
|
properties_panel = properties.build_properties_panel(
|
||||||
frame_editor, comp, on_change=refresh_all_views, on_close=dismiss_properties,
|
frame_editor, comp, on_change=refresh_all_views, on_close=dismiss_properties,
|
||||||
on_dirty_change=refresh_dirty_display,
|
on_dirty_change=refresh_dirty_display,
|
||||||
@@ -183,20 +223,60 @@ def dismiss_properties(restore_canvas=True):
|
|||||||
refresh_dirty_display()
|
refresh_dirty_display()
|
||||||
|
|
||||||
if restore_canvas:
|
if restore_canvas:
|
||||||
# Repacked in the order the pane was built in, so the scrollbars keep their edges.
|
_give_editor_pane_back()
|
||||||
canvas_vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
canvas_hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
|
||||||
app_state.canvas.pack(fill=tk.BOTH, expand=True)
|
|
||||||
app_state.clear_status()
|
|
||||||
|
|
||||||
|
|
||||||
def close_properties(force=False):
|
def show_link_view(comp):
|
||||||
"""Asks about unsaved edits, then takes the view down. True if the pane is now free.
|
"""Puts one component's links in the editor pane, in place of the wiresheet.
|
||||||
|
|
||||||
Every way of leaving the Properties view goes through this, so "you have not saved
|
The same trade the Properties view makes, and for the same reason: the sheet and a
|
||||||
that" is asked once, in one place, whether the user pressed Close, picked another
|
picture of one component's wiring are two answers to the same question, so only one
|
||||||
sheet, walked the history or opened a different component.
|
of them should be on screen at a time.
|
||||||
"""
|
"""
|
||||||
|
global link_panel
|
||||||
|
if comp is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not close_editor_panel():
|
||||||
|
return
|
||||||
|
|
||||||
|
_take_editor_pane()
|
||||||
|
link_panel = link_view.build_link_view_panel(
|
||||||
|
frame_editor, comp, on_open_link=open_link_from_link_view,
|
||||||
|
on_close=dismiss_link_view,
|
||||||
|
)
|
||||||
|
link_panel.frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
app_state.set_status(f"Link View: {comp.get('name', '?')}")
|
||||||
|
|
||||||
|
|
||||||
|
def dismiss_link_view(restore_canvas=True):
|
||||||
|
"""Takes the Link View down and gives the pane back.
|
||||||
|
|
||||||
|
Nothing is ever asked here: the view has changed nothing, so there is nothing to
|
||||||
|
lose by leaving it.
|
||||||
|
"""
|
||||||
|
global link_panel
|
||||||
|
if link_panel is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
link_panel.destroy()
|
||||||
|
link_panel = None
|
||||||
|
|
||||||
|
if restore_canvas:
|
||||||
|
_give_editor_pane_back()
|
||||||
|
|
||||||
|
|
||||||
|
def close_editor_panel(force=False):
|
||||||
|
"""Frees the editor pane, asking about unsaved edits first. True if it is now free.
|
||||||
|
|
||||||
|
Every way of leaving a panel goes through this, so "you have not saved that" is
|
||||||
|
asked once, in one place, whether the user pressed Close, picked another sheet,
|
||||||
|
walked the history or opened a different component. Only the Properties view has
|
||||||
|
anything to ask about; the Link View simply stands down.
|
||||||
|
"""
|
||||||
|
if link_panel is not None:
|
||||||
|
dismiss_link_view()
|
||||||
|
return True
|
||||||
if properties_panel is None:
|
if properties_panel is None:
|
||||||
return True
|
return True
|
||||||
if force:
|
if force:
|
||||||
@@ -208,7 +288,7 @@ def close_properties(force=False):
|
|||||||
def _restore_tree_selection():
|
def _restore_tree_selection():
|
||||||
"""Puts the tree back on the sheet that is actually showing, after a refused change."""
|
"""Puts the tree back on the sheet that is actually showing, after a refused change."""
|
||||||
global _restoring_selection
|
global _restoring_selection
|
||||||
item_id = _tree_item_for_element(properties_return_sheet)
|
item_id = _tree_item_for_element(panel_return_sheet)
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -378,13 +458,22 @@ def on_canvas_wheel_zoom(event):
|
|||||||
|
|
||||||
def on_tree_select(event):
|
def on_tree_select(event):
|
||||||
"""Triggered when a branch node is clicked in the Navigation view."""
|
"""Triggered when a branch node is clicked in the Navigation view."""
|
||||||
|
global _pending_link_selection
|
||||||
|
|
||||||
|
# Read and cleared first thing, so a link a jump asked for is only ever picked out
|
||||||
|
# by the very selection that jump made. A selection that turns out not to paint a
|
||||||
|
# sheet -- one being put back after a refusal, one of nothing at all -- drops the
|
||||||
|
# request rather than leaving it to be applied to some later sheet.
|
||||||
|
pending_link = _pending_link_selection
|
||||||
|
_pending_link_selection = None
|
||||||
|
|
||||||
if _restoring_selection:
|
if _restoring_selection:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Choosing a node means "show me that sheet", so the Properties view stands down
|
# Choosing a node means "show me that sheet", so whatever has the pane stands down
|
||||||
# rather than leaving the selection looking like it did nothing. Refusing to leave
|
# rather than leaving the selection looking like it did nothing. Refusing to leave
|
||||||
# has to put the tree back on the sheet that is still showing.
|
# has to put the tree back on the sheet that is still showing.
|
||||||
if not close_properties():
|
if not close_editor_panel():
|
||||||
_restore_tree_selection()
|
_restore_tree_selection()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -412,6 +501,10 @@ def on_tree_select(event):
|
|||||||
|
|
||||||
wiresheet.render_components(components_to_render)
|
wiresheet.render_components(components_to_render)
|
||||||
|
|
||||||
|
# The sheet is painted now, so a link the jump asked for has wires to be found among.
|
||||||
|
if pending_link is not None:
|
||||||
|
select_link_on_open_sheet(pending_link)
|
||||||
|
|
||||||
|
|
||||||
def delete_selected_link():
|
def delete_selected_link():
|
||||||
"""Removes the currently selected link from the DOM and repaints."""
|
"""Removes the currently selected link from the DOM and repaints."""
|
||||||
@@ -516,26 +609,63 @@ def go_to_link_sheet():
|
|||||||
app_state.set_status("This link has no other sheet to go to.", warn=True)
|
app_state.set_status("This link has no other sheet to go to.", warn=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
sheet_element, _ = destination
|
go_to_sheet_with_link(destination[0], link_elem)
|
||||||
|
|
||||||
|
|
||||||
|
def go_to_sheet_with_link(sheet_element, link_elem):
|
||||||
|
"""Opens a sheet and picks out one link on it.
|
||||||
|
|
||||||
|
Shared by the link popup's jump and the Link View, so following a link lands the
|
||||||
|
same way whichever of them was used to follow it.
|
||||||
|
"""
|
||||||
|
global _pending_link_selection
|
||||||
|
|
||||||
item_id = tree_item_for_sheet(sheet_element)
|
item_id = tree_item_for_sheet(sheet_element)
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
app_state.set_status(
|
app_state.set_status(
|
||||||
f"'{sheet_element.get('name', 'That sheet')}' is not in the navigation tree.",
|
f"'{link_view.sheet_display_name(sheet_element)}' is not in the "
|
||||||
|
"navigation tree.",
|
||||||
warn=True,
|
warn=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Selecting the node is what paints the sheet, and painting a sheet clears whatever
|
||||||
|
# link was selected -- so the link is left as a request for the selection handler to
|
||||||
|
# honour once it has painted, rather than picked out here. An idle callback is not
|
||||||
|
# good enough: ttk dispatches its own selection event later than one of those, so a
|
||||||
|
# link selected from one is wiped by the repaint that follows it.
|
||||||
|
_pending_link_selection = link_elem
|
||||||
|
|
||||||
tree = app_state.tree
|
tree = app_state.tree
|
||||||
# Selecting the node is what paints the sheet, and what records the visit so Back
|
# This is also what records the visit, so Back returns to the sheet the link was
|
||||||
# returns to the sheet the link was picked on.
|
# followed from.
|
||||||
tree.selection_set(item_id)
|
tree.selection_set(item_id)
|
||||||
tree.focus(item_id)
|
tree.focus(item_id)
|
||||||
tree.see(item_id)
|
tree.see(item_id)
|
||||||
|
|
||||||
# ttk queues its selection event rather than dispatching it here, so the sheet is
|
|
||||||
# not painted yet -- and painting a sheet clears whatever link was selected. Picking
|
def open_link_from_link_view(link_elem):
|
||||||
# the link out is therefore left until that has happened.
|
"""Link View: leave for the wiresheet this link leads to, with the link selected.
|
||||||
tree.after_idle(select_link_on_open_sheet, link_elem)
|
|
||||||
|
Where it leads is the sheet of the end that is not the component the view is about:
|
||||||
|
a link that stays on one sheet goes back to that sheet, where both ends are drawn,
|
||||||
|
and one that crosses to another sheet opens that other sheet -- which is the end the
|
||||||
|
view could only show as a knob. A reference the document cannot resolve has no sheet
|
||||||
|
of its own, so it falls back to the one the component itself is on.
|
||||||
|
"""
|
||||||
|
focus = link_panel.comp if link_panel is not None else None
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
if focus is None or root is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
peer = next((component for component in link_endpoint_components(link_elem)
|
||||||
|
if component is not None and component is not focus), focus)
|
||||||
|
sheet_element = find_parent_element(root, peer)
|
||||||
|
|
||||||
|
# The sheet needs the pane back before it can be scrolled to the link: a hidden
|
||||||
|
# canvas has no width to centre anything in.
|
||||||
|
dismiss_link_view()
|
||||||
|
go_to_sheet_with_link(sheet_element, link_elem)
|
||||||
|
|
||||||
|
|
||||||
def select_link_on_open_sheet(link_elem):
|
def select_link_on_open_sheet(link_elem):
|
||||||
@@ -553,7 +683,7 @@ def delete_selected_component(event=None):
|
|||||||
# Del is bound application-wide, so it also reaches a Properties field being typed in
|
# Del is bound application-wide, so it also reaches a Properties field being typed in
|
||||||
# and every other window. A keystroke aimed at a text field must not delete a
|
# and every other window. A keystroke aimed at a text field must not delete a
|
||||||
# component, and neither must one pressed while the sheet is not even on screen.
|
# component, and neither must one pressed while the sheet is not even on screen.
|
||||||
if properties_panel is not None:
|
if editor_panel_showing():
|
||||||
return
|
return
|
||||||
|
|
||||||
focused = app_state.window.focus_get()
|
focused = app_state.window.focus_get()
|
||||||
@@ -863,6 +993,10 @@ def fill_component_popup(menu, comp):
|
|||||||
label="Properties",
|
label="Properties",
|
||||||
command=lambda: show_properties(comp),
|
command=lambda: show_properties(comp),
|
||||||
)
|
)
|
||||||
|
menu.add_command(
|
||||||
|
label="Link View",
|
||||||
|
command=lambda: show_link_view(comp),
|
||||||
|
)
|
||||||
menu.add_separator()
|
menu.add_separator()
|
||||||
menu.add_command(
|
menu.add_command(
|
||||||
label="Delete Component",
|
label="Delete Component",
|
||||||
@@ -1260,7 +1394,7 @@ def _show_sheet_history_entry(position):
|
|||||||
|
|
||||||
def go_back(event=None):
|
def go_back(event=None):
|
||||||
"""View menu and toolbar: step to the previously opened sheet."""
|
"""View menu and toolbar: step to the previously opened sheet."""
|
||||||
if not close_properties():
|
if not close_editor_panel():
|
||||||
return "break"
|
return "break"
|
||||||
_prune_sheet_history()
|
_prune_sheet_history()
|
||||||
if history_index > 0:
|
if history_index > 0:
|
||||||
@@ -1272,7 +1406,7 @@ def go_back(event=None):
|
|||||||
|
|
||||||
def go_forward(event=None):
|
def go_forward(event=None):
|
||||||
"""View menu and toolbar: step back towards the most recently opened sheet."""
|
"""View menu and toolbar: step back towards the most recently opened sheet."""
|
||||||
if not close_properties():
|
if not close_editor_panel():
|
||||||
return "break"
|
return "break"
|
||||||
_prune_sheet_history()
|
_prune_sheet_history()
|
||||||
if 0 <= history_index < len(sheet_history) - 1:
|
if 0 <= history_index < len(sheet_history) - 1:
|
||||||
@@ -1293,8 +1427,9 @@ def on_tree_filled():
|
|||||||
|
|
||||||
# A different file has no such component, and a rebuild after a delete may have taken
|
# A different file has no such component, and a rebuild after a delete may have taken
|
||||||
# it. Neither is a moment to ask about edits that now have nowhere to go.
|
# it. Neither is a moment to ask about edits that now have nowhere to go.
|
||||||
if properties_panel is not None and not _component_in_document(properties_panel.comp):
|
panel = properties_panel or link_panel
|
||||||
close_properties(force=True)
|
if panel is not None and not _component_in_document(panel.comp):
|
||||||
|
close_editor_panel(force=True)
|
||||||
|
|
||||||
# The root row is new after every rebuild, so its colour has to be reapplied.
|
# The root row is new after every rebuild, so its colour has to be reapplied.
|
||||||
refresh_dirty_display()
|
refresh_dirty_display()
|
||||||
@@ -1580,11 +1715,7 @@ def _rebuild_link_context_menu():
|
|||||||
if destination is None:
|
if destination is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
sheet_element = destination[0]
|
sheet_name = link_view.sheet_display_name(destination[0])
|
||||||
sheet_name = sheet_element.get("name")
|
|
||||||
if not sheet_name:
|
|
||||||
# The app element, which the tree shows under the file's own name.
|
|
||||||
sheet_name = os.path.basename(app_state.current_file_path or "app")
|
|
||||||
|
|
||||||
link_context_menu.add_separator()
|
link_context_menu.add_separator()
|
||||||
link_context_menu.add_command(
|
link_context_menu.add_command(
|
||||||
|
|||||||
+105
-85
@@ -577,15 +577,17 @@ def resolve_slot_anchor(comp_tag, slot_name):
|
|||||||
return box, box[1] + offset
|
return box, box[1] + offset
|
||||||
|
|
||||||
|
|
||||||
def draw_knob(x, y, on_sheet, off_sheet, extra_tags=()):
|
def draw_knob(canvas, x, y, on_sheet, off_sheet, extra_tags=()):
|
||||||
"""Marks a slot whose links cannot be shown as a visible wire.
|
"""Marks a slot whose links cannot be shown as a visible wire.
|
||||||
|
|
||||||
Painted in the wire colour when the far end is on this sheet but unreachable, and
|
Painted in the wire colour when the far end is on this sheet but unreachable, and
|
||||||
grey when it is on another wiresheet, so the two cases read differently at a
|
grey when it is on another wiresheet, so the two cases read differently at a
|
||||||
glance. A slot carrying both is split down the middle, wire colour on the left
|
glance. A slot carrying both is split down the middle, wire colour on the left
|
||||||
and grey on the right.
|
and grey on the right.
|
||||||
|
|
||||||
|
The canvas is passed in rather than read off `app_state`: the Link View draws the
|
||||||
|
same grey marker on a canvas of its own, and a knob is a knob wherever it is shown.
|
||||||
"""
|
"""
|
||||||
canvas = app_state.canvas
|
|
||||||
tags = ("link", "link_knob") + tuple(extra_tags)
|
tags = ("link", "link_knob") + tuple(extra_tags)
|
||||||
box = (x - KNOB_RADIUS, y - KNOB_RADIUS, x + KNOB_RADIUS, y + KNOB_RADIUS)
|
box = (x - KNOB_RADIUS, y - KNOB_RADIUS, x + KNOB_RADIUS, y + KNOB_RADIUS)
|
||||||
|
|
||||||
@@ -737,7 +739,8 @@ def draw_links():
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (x, y), entry in knobs.items():
|
for (x, y), entry in knobs.items():
|
||||||
draw_knob(x, y, entry["on_sheet"], entry["off_sheet"], tuple(entry["tags"]))
|
draw_knob(canvas, x, y, entry["on_sheet"], entry["off_sheet"],
|
||||||
|
tuple(entry["tags"]))
|
||||||
|
|
||||||
# Wires sit behind the boxes so they never paint over a neighbouring component.
|
# Wires sit behind the boxes so they never paint over a neighbouring component.
|
||||||
# Nothing should actually be back there now, since a wire that cannot avoid a box is
|
# Nothing should actually be back there now, since a wire that cannot avoid a box is
|
||||||
@@ -832,6 +835,96 @@ def deselect_link():
|
|||||||
|
|
||||||
# --- Component painting ---
|
# --- Component painting ---
|
||||||
|
|
||||||
|
def draw_component_box(canvas, comp, x, y, fonts, linked_slot_index, tags,
|
||||||
|
outline_tags=()):
|
||||||
|
"""Paints one component box with its top-left corner at (x, y).
|
||||||
|
|
||||||
|
Returns (width, height, slot_offsets), where slot_offsets maps a slot name to the y
|
||||||
|
offset of its row's centre from the box top -- which is what a wire anchors to.
|
||||||
|
|
||||||
|
Every caller gets the same box. The wiresheet paints these at their meta positions
|
||||||
|
and the Link View arranges them around one component; a box that read differently
|
||||||
|
between the two would suggest the user was looking at something else. `tags` goes on
|
||||||
|
every item, so the whole box is moved and picked as one, and the sheet adds
|
||||||
|
`outline_tags` to the outline alone -- the item it strokes to show a selection.
|
||||||
|
"""
|
||||||
|
name_font, type_font, slot_font = fonts
|
||||||
|
|
||||||
|
(box_width, box_height, slot_data_rows,
|
||||||
|
full_display_name, display_type) = measure_box(comp, fonts, linked_slot_index)
|
||||||
|
|
||||||
|
# Filled in as the rows are drawn below; keyed by slot name for link anchoring.
|
||||||
|
slot_offsets = {}
|
||||||
|
|
||||||
|
create_round_rectangle(
|
||||||
|
canvas, x, y, x + box_width, y + box_height,
|
||||||
|
radius=5, fill="white", outline="#7f9db9", width=2,
|
||||||
|
tags=tuple(tags) + tuple(outline_tags)
|
||||||
|
)
|
||||||
|
# Draw Header bar
|
||||||
|
canvas.create_rectangle(
|
||||||
|
x + 1, y + 1, x + box_width - 1, y + HEADER_HEIGHT,
|
||||||
|
fill="#dce6f2", outline="", tags=tags
|
||||||
|
)
|
||||||
|
canvas.create_line(
|
||||||
|
x, y + HEADER_HEIGHT, x + box_width, y + HEADER_HEIGHT,
|
||||||
|
fill="#7f9db9", tags=tags
|
||||||
|
)
|
||||||
|
|
||||||
|
# Header textual labels, ellipsised to leave room for the indicator square
|
||||||
|
header_text_width = box_width - 26
|
||||||
|
canvas.create_text(
|
||||||
|
x + 5, y + 8, anchor="w",
|
||||||
|
text=fit_text(full_display_name, name_font, header_text_width),
|
||||||
|
font=name_font, fill="black", tags=tags
|
||||||
|
)
|
||||||
|
canvas.create_text(
|
||||||
|
x + 5, y + 19, anchor="w",
|
||||||
|
text=fit_text(display_type, type_font, header_text_width),
|
||||||
|
font=type_font, fill="#555555", tags=tags
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quick access indicator icon background slot placeholder
|
||||||
|
canvas.create_rectangle(
|
||||||
|
x + box_width - 17, y + 6, x + box_width - 5, y + 18,
|
||||||
|
fill="#ffe699", outline="#b4c6e7", tags=tags
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render dynamic slots rows
|
||||||
|
current_row_y = y + HEADER_HEIGHT
|
||||||
|
for idx, (raw_name, s_name, val_str) in enumerate(slot_data_rows):
|
||||||
|
row_bg = "#eaeaea" if idx % 2 == 0 else "#dfdfdf"
|
||||||
|
slot_offsets[raw_name] = (current_row_y + (SLOT_ROW_HEIGHT // 2)) - y
|
||||||
|
|
||||||
|
canvas.create_rectangle(
|
||||||
|
x + 1, current_row_y, x + box_width - 1, current_row_y + SLOT_ROW_HEIGHT,
|
||||||
|
fill=row_bg, outline="", tags=tags
|
||||||
|
)
|
||||||
|
|
||||||
|
# Value keeps its full text; the name yields space to it when the row is tight
|
||||||
|
value_width = slot_font.measure(val_str)
|
||||||
|
label_space = box_width - 16 - value_width
|
||||||
|
|
||||||
|
canvas.create_text(
|
||||||
|
x + 6, current_row_y + (SLOT_ROW_HEIGHT // 2), anchor="w",
|
||||||
|
text=fit_text(s_name, slot_font, label_space),
|
||||||
|
font=slot_font, fill="black", tags=tags
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.create_text(
|
||||||
|
x + box_width - 6, current_row_y + (SLOT_ROW_HEIGHT // 2), anchor="e",
|
||||||
|
text=val_str, font=slot_font, fill="#333333", tags=tags
|
||||||
|
)
|
||||||
|
|
||||||
|
current_row_y += SLOT_ROW_HEIGHT
|
||||||
|
canvas.create_line(
|
||||||
|
x, current_row_y, x + box_width, current_row_y,
|
||||||
|
fill="#b8b8b8", tags=tags
|
||||||
|
)
|
||||||
|
|
||||||
|
return box_width, box_height, slot_offsets
|
||||||
|
|
||||||
|
|
||||||
def render_components(components_to_render):
|
def render_components(components_to_render):
|
||||||
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
|
"""Handles parsing and visual painting of given component listings on canvas with slot attributes."""
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
@@ -857,10 +950,6 @@ def render_components(components_to_render):
|
|||||||
max_reached_x = 1000
|
max_reached_x = 1000
|
||||||
max_reached_y = 1000
|
max_reached_y = 1000
|
||||||
|
|
||||||
header_height = HEADER_HEIGHT
|
|
||||||
slot_row_height = SLOT_ROW_HEIGHT
|
|
||||||
footer_height = FOOTER_HEIGHT
|
|
||||||
|
|
||||||
for comp in components_to_render:
|
for comp in components_to_render:
|
||||||
meta_prop = comp.find("prop[@name='meta']")
|
meta_prop = comp.find("prop[@name='meta']")
|
||||||
meta_str = meta_prop.get("val") if meta_prop is not None else None
|
meta_str = meta_prop.get("val") if meta_prop is not None else None
|
||||||
@@ -869,90 +958,21 @@ def render_components(components_to_render):
|
|||||||
coord_x = raw_x * meta_scale
|
coord_x = raw_x * meta_scale
|
||||||
coord_y = raw_y * meta_scale
|
coord_y = raw_y * meta_scale
|
||||||
|
|
||||||
(box_width, box_height, slot_data_rows,
|
comp_tag = f"comp_group_{id(comp)}"
|
||||||
full_display_name, display_type) = measure_box(comp, fonts, linked_slot_index)
|
app_state.canvas_comp_map[comp_tag] = comp
|
||||||
|
app_state.canvas_tag_by_element[comp] = comp_tag
|
||||||
|
|
||||||
|
box_width, box_height, slot_offsets = draw_component_box(
|
||||||
|
canvas, comp, coord_x, coord_y, fonts, linked_slot_index,
|
||||||
|
("component", comp_tag), outline_tags=("block_outline",)
|
||||||
|
)
|
||||||
|
app_state.component_slot_offsets[comp_tag] = slot_offsets
|
||||||
|
|
||||||
if coord_x + box_width > max_reached_x:
|
if coord_x + box_width > max_reached_x:
|
||||||
max_reached_x = coord_x + box_width
|
max_reached_x = coord_x + box_width
|
||||||
if coord_y + box_height > max_reached_y:
|
if coord_y + box_height > max_reached_y:
|
||||||
max_reached_y = coord_y + box_height
|
max_reached_y = coord_y + box_height
|
||||||
|
|
||||||
comp_tag = f"comp_group_{id(comp)}"
|
|
||||||
app_state.canvas_comp_map[comp_tag] = comp
|
|
||||||
app_state.canvas_tag_by_element[comp] = comp_tag
|
|
||||||
|
|
||||||
# Filled in as rows are drawn below; keyed by slot name for link anchoring.
|
|
||||||
slot_offsets = {}
|
|
||||||
app_state.component_slot_offsets[comp_tag] = slot_offsets
|
|
||||||
|
|
||||||
create_round_rectangle(
|
|
||||||
canvas, coord_x, coord_y, coord_x + box_width, coord_y + box_height,
|
|
||||||
radius=5, fill="white", outline="#7f9db9", width=2,
|
|
||||||
tags=("component", comp_tag, "block_outline")
|
|
||||||
)
|
|
||||||
# Draw Header bar
|
|
||||||
canvas.create_rectangle(
|
|
||||||
coord_x + 1, coord_y + 1, coord_x + box_width - 1, coord_y + header_height,
|
|
||||||
fill="#dce6f2", outline="", tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
canvas.create_line(
|
|
||||||
coord_x, coord_y + header_height, coord_x + box_width, coord_y + header_height,
|
|
||||||
fill="#7f9db9", tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Header textual labels, ellipsised to leave room for the indicator square
|
|
||||||
header_text_width = box_width - 26
|
|
||||||
canvas.create_text(
|
|
||||||
coord_x + 5, coord_y + 8, anchor="w",
|
|
||||||
text=fit_text(full_display_name, name_font, header_text_width),
|
|
||||||
font=name_font, fill="black",
|
|
||||||
tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
canvas.create_text(
|
|
||||||
coord_x + 5, coord_y + 19, anchor="w",
|
|
||||||
text=fit_text(display_type, type_font, header_text_width),
|
|
||||||
font=type_font, fill="#555555",
|
|
||||||
tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Quick access indicator icon background slot placeholder
|
|
||||||
canvas.create_rectangle(
|
|
||||||
coord_x + box_width - 17, coord_y + 6, coord_x + box_width - 5, coord_y + 18,
|
|
||||||
fill="#ffe699", outline="#b4c6e7", tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Render dynamic slots rows
|
|
||||||
current_row_y = coord_y + header_height
|
|
||||||
for idx, (raw_name, s_name, val_str) in enumerate(slot_data_rows):
|
|
||||||
row_bg = "#eaeaea" if idx % 2 == 0 else "#dfdfdf"
|
|
||||||
slot_offsets[raw_name] = (current_row_y + (slot_row_height // 2)) - coord_y
|
|
||||||
|
|
||||||
canvas.create_rectangle(
|
|
||||||
coord_x + 1, current_row_y, coord_x + box_width - 1, current_row_y + slot_row_height,
|
|
||||||
fill=row_bg, outline="", tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Value keeps its full text; the name yields space to it when the row is tight
|
|
||||||
value_width = slot_font.measure(val_str)
|
|
||||||
label_space = box_width - 16 - value_width
|
|
||||||
|
|
||||||
canvas.create_text(
|
|
||||||
coord_x + 6, current_row_y + (slot_row_height // 2), anchor="w",
|
|
||||||
text=fit_text(s_name, slot_font, label_space),
|
|
||||||
font=slot_font, fill="black", tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
|
|
||||||
canvas.create_text(
|
|
||||||
coord_x + box_width - 6, current_row_y + (slot_row_height // 2), anchor="e",
|
|
||||||
text=val_str, font=slot_font, fill="#333333", tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
|
|
||||||
current_row_y += slot_row_height
|
|
||||||
canvas.create_line(
|
|
||||||
coord_x, current_row_y, coord_x + box_width, current_row_y,
|
|
||||||
fill="#b8b8b8", tags=("component", comp_tag)
|
|
||||||
)
|
|
||||||
|
|
||||||
draw_links()
|
draw_links()
|
||||||
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)
|
apply_sheet_zoom(fonts, max_reached_x + 200, max_reached_y + 200)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user