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:
@@ -6,6 +6,7 @@ import tkinter as tk
|
||||
from tkinter import filedialog, font as tkfont, messagebox, ttk
|
||||
|
||||
import app_state
|
||||
import link_view
|
||||
import manage_kits
|
||||
import palette
|
||||
import properties
|
||||
@@ -62,17 +63,25 @@ zoom_label = None
|
||||
menu_view = None
|
||||
show_left_column_var = None
|
||||
|
||||
# The Properties view takes the editor pane over from the canvas rather than floating
|
||||
# above it, so at most one of them is on screen and this is which. None means the sheet.
|
||||
# The Properties and Link views each take the editor pane over from the canvas rather
|
||||
# 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
|
||||
link_panel = None
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
properties_return_sheet = None
|
||||
panel_return_sheet = None
|
||||
_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():
|
||||
"""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)
|
||||
wiresheet.render_current_sheet()
|
||||
|
||||
# An undo or a cascading delete can take the component the Properties view is showing
|
||||
# out of the document underneath it. Nothing it could display then would be true.
|
||||
# An undo or a cascading delete can take the component a panel is showing out of the
|
||||
# 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):
|
||||
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):
|
||||
@@ -125,40 +142,63 @@ def _component_in_document(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
|
||||
than going through refresh_all_views, so the panel would otherwise be left describing
|
||||
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:
|
||||
close_properties(force=True)
|
||||
close_editor_panel(force=True)
|
||||
|
||||
|
||||
def show_properties(comp):
|
||||
"""Puts one component's slots in the editor pane, in place of the wiresheet.
|
||||
def editor_panel_showing():
|
||||
"""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
|
||||
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.
|
||||
"""
|
||||
global properties_panel, properties_return_sheet
|
||||
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_properties():
|
||||
return
|
||||
|
||||
properties_return_sheet = app_state.current_sheet_parent
|
||||
global panel_return_sheet
|
||||
panel_return_sheet = app_state.current_sheet_parent
|
||||
|
||||
canvas_vscroll.pack_forget()
|
||||
canvas_hscroll.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(
|
||||
frame_editor, comp, on_change=refresh_all_views, on_close=dismiss_properties,
|
||||
on_dirty_change=refresh_dirty_display,
|
||||
@@ -183,20 +223,60 @@ def dismiss_properties(restore_canvas=True):
|
||||
refresh_dirty_display()
|
||||
|
||||
if restore_canvas:
|
||||
# Repacked in the order the pane was built in, so the scrollbars keep their edges.
|
||||
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()
|
||||
_give_editor_pane_back()
|
||||
|
||||
|
||||
def close_properties(force=False):
|
||||
"""Asks about unsaved edits, then takes the view down. True if the pane is now free.
|
||||
def show_link_view(comp):
|
||||
"""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
|
||||
that" is asked once, in one place, whether the user pressed Close, picked another
|
||||
sheet, walked the history or opened a different component.
|
||||
The same trade the Properties view makes, and for the same reason: the sheet and a
|
||||
picture of one component's wiring are two answers to the same question, so only one
|
||||
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:
|
||||
return True
|
||||
if force:
|
||||
@@ -208,7 +288,7 @@ def close_properties(force=False):
|
||||
def _restore_tree_selection():
|
||||
"""Puts the tree back on the sheet that is actually showing, after a refused change."""
|
||||
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:
|
||||
return
|
||||
|
||||
@@ -378,13 +458,22 @@ def on_canvas_wheel_zoom(event):
|
||||
|
||||
def on_tree_select(event):
|
||||
"""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:
|
||||
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
|
||||
# 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()
|
||||
return
|
||||
|
||||
@@ -412,6 +501,10 @@ def on_tree_select(event):
|
||||
|
||||
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():
|
||||
"""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)
|
||||
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)
|
||||
if item_id is None:
|
||||
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,
|
||||
)
|
||||
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
|
||||
# Selecting the node is what paints the sheet, and what records the visit so Back
|
||||
# returns to the sheet the link was picked on.
|
||||
# This is also what records the visit, so Back returns to the sheet the link was
|
||||
# followed from.
|
||||
tree.selection_set(item_id)
|
||||
tree.focus(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
|
||||
# the link out is therefore left until that has happened.
|
||||
tree.after_idle(select_link_on_open_sheet, link_elem)
|
||||
|
||||
def open_link_from_link_view(link_elem):
|
||||
"""Link View: leave for the wiresheet this link leads to, with the link selected.
|
||||
|
||||
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):
|
||||
@@ -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
|
||||
# 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.
|
||||
if properties_panel is not None:
|
||||
if editor_panel_showing():
|
||||
return
|
||||
|
||||
focused = app_state.window.focus_get()
|
||||
@@ -863,6 +993,10 @@ def fill_component_popup(menu, comp):
|
||||
label="Properties",
|
||||
command=lambda: show_properties(comp),
|
||||
)
|
||||
menu.add_command(
|
||||
label="Link View",
|
||||
command=lambda: show_link_view(comp),
|
||||
)
|
||||
menu.add_separator()
|
||||
menu.add_command(
|
||||
label="Delete Component",
|
||||
@@ -1260,7 +1394,7 @@ def _show_sheet_history_entry(position):
|
||||
|
||||
def go_back(event=None):
|
||||
"""View menu and toolbar: step to the previously opened sheet."""
|
||||
if not close_properties():
|
||||
if not close_editor_panel():
|
||||
return "break"
|
||||
_prune_sheet_history()
|
||||
if history_index > 0:
|
||||
@@ -1272,7 +1406,7 @@ def go_back(event=None):
|
||||
|
||||
def go_forward(event=None):
|
||||
"""View menu and toolbar: step back towards the most recently opened sheet."""
|
||||
if not close_properties():
|
||||
if not close_editor_panel():
|
||||
return "break"
|
||||
_prune_sheet_history()
|
||||
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
|
||||
# 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):
|
||||
close_properties(force=True)
|
||||
panel = properties_panel or link_panel
|
||||
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.
|
||||
refresh_dirty_display()
|
||||
@@ -1580,11 +1715,7 @@ def _rebuild_link_context_menu():
|
||||
if destination is None:
|
||||
return
|
||||
|
||||
sheet_element = 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")
|
||||
sheet_name = link_view.sheet_display_name(destination[0])
|
||||
|
||||
link_context_menu.add_separator()
|
||||
link_context_menu.add_command(
|
||||
|
||||
Reference in New Issue
Block a user