Skip to content

GUI: the editor canvas

How a palette drop, a connection drag, a node drag, a delete, an undo and Organize reach the model and come back as a redrawn scene. The canvas (CanvasController) draws and reports; it never edits the case. Every edit goes through MainWindow, which takes an undo checkpoint, mutates the model and lets the model's signals redraw. The contracts of the functions below are in their docstrings (the standard is Function contracts and flow charts); the open, edit and save path has its own chart (GUI: open, edit, validate, save).

Qt slots and VTK observers swallow exceptions: one that escapes is printed and the event loop goes on, so the user sees nothing. A box labelled "dialog" or "status" is shown on purpose; "printed, nothing shown" and "silently" mark the paths where it is not.

A palette drop

What enters: a drag from the component palette released over the canvas. What leaves: one new component in the case, on the canvas at the drop point and in the layout, one undo entry, or nothing.

flowchart TD
    D["drop from the palette"] --> DE{"_DropEnabledQtInteractor.dropEvent"}
    DE -- "foreign MIME, bytes not UTF-8, degenerate view" --> IG["event ignored, no signal, no message"]
    DE --> SIG["component_dropped: kind, world point on z = 0"]
    SIG --> OD["MainWindow._on_component_dropped"]
    OD --> PR{"MainWindow._on_palette_request"}
    PR --> CK["MainWindow._push_undo: History.push, deep copy"]
    CK -- "state cannot be copied" --> X1["exception: printed, nothing added"]
    CK --> KD{"kind"}
    KD -- "unknown" --> X2["checkpoint taken back, status: Unknown component kind"]
    KD -- "probe, no pipe" --> X3["checkpoint taken back, status: Add a pipe first"]
    KD -- "pipe, junction, BC, turbo, shaft" --> AN{"CanvasController.add_node"}
    AN -- "id already on the canvas, or its actor names clash" --> X4["ValueError: printed, checkpoint stays"]
    AN --> DR["PvNode.add_to_plotter: block, label, decorations, port spheres"]
    DR --> MD["CaseModel add_*: spec and layout position"]
    MD --> SS["structure signal: lines, list, preview, dirty"]
    SS --> RC["MainWindow._refresh_connections: case_connections, then refresh_connections"]

A node is drawn before the model learns of it. If the canvas raises, the checkpoint is already pushed and the model unchanged, so Undo then restores the same state; no code takes it back.

A connection drag

What enters: a left press on a port sphere, a drag and a release. What leaves: a pipe end wired to a BC, a junction, a turbo or a cylinder in the case, one undo entry, or nothing. Over a refused target the rule's reason is shown as a tooltip at the cursor, and the rubber band turns red; the tooltip is hidden when the cursor leaves the target (since 2026-10-11; before, the reason was computed on every hover and never shown).

flowchart TD
    P["left press"] --> PP{"CanvasController._on_left_press"}
    PP -- "probe mode" --> PRB["_handle_probe_click: probe_requested"]
    PP -- "port under the cursor" --> CD["_connect_from_port set, event aborted"]
    PP -- "node, shift, locked" --> ND["select, shift-select or arm a node drag"]
    PP -- "empty space" --> PAN["deselect and start a pan"]
    CD --> MV["CanvasController._on_mouse_move"]
    MV --> RB["CanvasController._update_rubber_band: snap to a port within 1.5 radii"]
    RB --> LG["legality_callback: MainWindow._connection_is_legal"]
    LG --> RL{"is_legal_connection"}
    RL -- "same port or same component" --> R1["red band: cannot connect a port to itself"]
    RL -- "two pipe ends" --> R2["red band: pipe-to-pipe connections must go through a junction"]
    RL -- "kinds never pair" --> R3["red band: incompatible port kinds"]
    RL -- "end already held by a BC, junction, turbo or cylinder" --> R4["red band: pipe end already attached"]
    RL -- "free end, or two shaft ports" --> GR["green band"]
    CD --> RE["left release: CanvasController._on_left_release"]
    RE --> FD{"CanvasController._finish_connect_drag"}
    FD -- "free air, own component" --> NO["nothing emitted, band removed, no message"]
    FD -- "illegal" --> NR["nothing emitted, band removed, the reason as a tooltip"]
    FD -- "callback raises" --> NX["band removed, state cleared, exception printed"]
    FD -- "legal" --> CR["connection_requested: source port, target port"]
    CR --> OC{"MainWindow._on_connection_requested"}
    OC -- "two shaft ports" --> SH["MainWindow._connect_shaft: its own checkpoint"]
    OC --> CK["MainWindow._push_undo"]
    CK --> KD{"consumer kind"}
    KD -- "no pipe end plus consumer, node missing or of another kind" --> RV["CaseModel.revert_pending_checkpoint: nothing changes, no message"]
    KD -- "bc_target" --> BC["BC pipe and end set, MainWindow._rekey_bc_nodes, PvNode.refresh_ports"]
    KD -- "junction" --> JN["PortSpec appended"]
    KD -- "turbo inlet or outlet by port name" --> TB["inlet or outlet set"]
    KD -- "cylinder" --> CY["default CylinderPortSpec appended"]
    BC --> ES["CaseModel.emit_structure_changed"]
    JN --> ES
    TB --> ES
    CY --> ES
    ES --> RC["MainWindow._refresh_connections"]

A turbo port is only ever named inlet or outlet (PvNode.build_ports), so the unnamed-port case the handler cannot take back is not reachable from a drag. A cylinder with no spec ports shows one fallback marker named "port"; dropping on it still appends port 0.

A node drag, a delete and Organize

A drag moves nodes on the canvas and reports the final position once; it pushes no undo entry (a move cannot be undone, but Organize can). A delete cascades through the model and then removes the node; Organize lays the case out and tweens the nodes there.

flowchart TD
    MM["CanvasController._on_mouse_move, armed drag"] --> DZ{"beyond the 0.05 dead zone?"}
    DZ -- no --> HOLD["node stays, event aborted"]
    DZ -- yes --> MT["PvNode.move_to, halos follow, snap if on"]
    MT --> RL["CanvasController._on_left_release"]
    RL -- "position unchanged" --> NS["no signal"]
    RL -- "moved" --> NMV["node_moved and connections_dirty"]
    NMV --> LP["MainWindow._on_node_moved: layout position, dirty, no undo entry"]
    NMV --> RC["MainWindow._refresh_connections"]
    DEL["Edit: Delete"] --> AD{"MainWindow.action_delete_selected"}
    AD -- "nothing selected" --> ST["status: Nothing selected, no checkpoint"]
    AD --> CK["MainWindow._push_undo"]
    CK --> CD{"CaseModel.delete_component"}
    CD -- "no match" --> RV["CaseModel.revert_pending_checkpoint, canvas node stays"]
    CD --> RN["CanvasController.remove_node: actors, halo, warning, selection"]
    RN --> BCS["the pipe's BC nodes keep their old canvas ids until the next rebuild"]
    ORG["View: Organize"] --> AO{"MainWindow.action_organize_layout"}
    AO -- "fewer than two components, all pinned, no match" --> MSG["status says why, no checkpoint"]
    AO --> CP{"compute_organized_positions"}
    CP -- "exception" --> CF["status: layout computation failed, no change"]
    CP --> AP["CanvasController.apply_layout: snap, tween 250 ms"]
    AP --> FN["CanvasController._layout_anim_tick, then _finish_layout_animation: node_moved, connections_dirty"]

Dragging a node does not rewire anything: the connection lines are rebuilt from the case (refresh_connections), which names every port by "::" and splits at the last "::", so a node id that contains "::" is wired like any other (since 2026-10-11).

Undo and redo

What enters: Ctrl+Z or Ctrl+Y. What leaves: the model holds the stored case and layout, the canvas is rebuilt from them, the case is dirty.

flowchart TD
    U["Edit: Undo, Redo"] --> AU{"MainWindow.action_undo, action_redo"}
    AU --> MU{"CaseModel.undo, redo"}
    MU -- "stack empty" --> E1["False: silent no-op"]
    MU --> HU{"History.undo, redo"}
    HU -- "live state cannot be copied" --> X1["exception: printed, stacks unchanged"]
    HU --> RP["CaseModel.replace: caseReplaced, path kept, dirty"]
    RP -- "replace raises" --> X2["the popped checkpoint is gone, printed"]
    RP --> SY["MainWindow._sync_case_to_canvas"]
    SY --> RR["render_case: canvas.clear, a node per component, one finalize_bulk_add"]
    RR --> ID{"id already on the canvas, or its actor names clash?"}
    ID -- "yes: BC, junction named like a pipe" --> SUF["numeric suffix _2, _3"]
    ID -- no --> OK["node at its layout position, made-up position recorded"]

The history keeps 50 checkpoints, deep copies taken before each discrete edit; a new edit clears the redo stack, and revert_push puts back exactly what the speculative push removed.

Functions

  • aeolus1d_gui/editor/canvas.py::_DropEnabledQtInteractor.dropEvent
  • aeolus1d_gui/editor/canvas.py::CanvasController.add_node
  • aeolus1d_gui/editor/canvas.py::CanvasController.remove_node
  • aeolus1d_gui/editor/canvas.py::CanvasController.refresh_connections
  • aeolus1d_gui/editor/canvas.py::CanvasController._on_left_press
  • aeolus1d_gui/editor/canvas.py::CanvasController._handle_probe_click
  • aeolus1d_gui/editor/canvas.py::CanvasController._on_mouse_move
  • aeolus1d_gui/editor/canvas.py::CanvasController._update_rubber_band
  • aeolus1d_gui/editor/canvas.py::CanvasController._on_left_release
  • aeolus1d_gui/editor/canvas.py::CanvasController._finish_connect_drag
  • aeolus1d_gui/editor/canvas.py::CanvasController.apply_layout
  • aeolus1d_gui/editor/canvas.py::CanvasController._layout_anim_tick
  • aeolus1d_gui/editor/canvas.py::CanvasController._finish_layout_animation
  • aeolus1d_gui/editor/nodes.py::PvNode.add_to_plotter
  • aeolus1d_gui/editor/nodes.py::PvNode.build_ports
  • aeolus1d_gui/editor/nodes.py::PvNode.refresh_ports
  • aeolus1d_gui/editor/nodes.py::PvNode.move_to
  • aeolus1d_gui/editor/rules.py::is_legal_connection
  • aeolus1d_gui/editor/history.py::History.push
  • aeolus1d_gui/editor/history.py::History.undo
  • aeolus1d_gui/editor/history.py::History.redo
  • aeolus1d_gui/editor/history.py::History.revert_push
  • aeolus1d_gui/editor/organize_layout.py::compute_organized_positions
  • aeolus1d_gui/editor/case_renderer.py::render_case
  • aeolus1d_gui/app.py::MainWindow._on_component_dropped
  • aeolus1d_gui/app.py::MainWindow._on_palette_request
  • aeolus1d_gui/app.py::MainWindow._push_undo
  • aeolus1d_gui/app.py::MainWindow._connection_is_legal
  • aeolus1d_gui/app.py::MainWindow._on_connection_requested
  • aeolus1d_gui/app.py::MainWindow._connect_shaft
  • aeolus1d_gui/app.py::MainWindow._rekey_bc_nodes
  • aeolus1d_gui/app.py::MainWindow._refresh_connections
  • aeolus1d_gui/app.py::MainWindow._on_node_moved
  • aeolus1d_gui/app.py::MainWindow.action_delete_selected
  • aeolus1d_gui/app.py::MainWindow.action_organize_layout
  • aeolus1d_gui/app.py::MainWindow.action_undo
  • aeolus1d_gui/app.py::MainWindow.action_redo
  • aeolus1d_gui/app.py::MainWindow._sync_case_to_canvas
  • aeolus1d_gui/model.py::CaseModel.push_undo
  • aeolus1d_gui/model.py::CaseModel.undo
  • aeolus1d_gui/model.py::CaseModel.redo
  • aeolus1d_gui/model.py::CaseModel.revert_pending_checkpoint
  • aeolus1d_gui/model.py::CaseModel.replace
  • aeolus1d_gui/model.py::CaseModel.emit_structure_changed
  • aeolus1d_gui/model.py::CaseModel.delete_component