Old Delphi Graph Editing Code

Created on 2020-08-31T03:35:41+00:00

Return to the Index

This card can also be read via Gemini.

We start with just a `graph node` object, something simple:

  TGraphNode = class(TObject)
    Title: String;
    X, Y, Width, Height: Integer;

    constructor Create;
    destructor Destroy; override;

    function Bounds: TRect;
  end;

Detecting graph nodes at point:

  procedure TForm1.GraphViewBoxMouseMove
    (Sender: TObject; Shift: TShiftState; X, Y: Integer);
  var
    Node: TGraphNode;
    Widget: TGraphNodeWidget;
    CursorPoint: TPoint;
    Bounds, Bounds2: TRect;
  begin
    CursorPoint := TPoint.Create(x, y);
    { for every node in the graph }
    for Node in Graph.nodes do begin
      Bounds := node.Bounds;
      Bounds.Offset(-GraphScrollX, -GraphScrollY);
      { if the cursor is over this node }
      if Bounds.Contains(CursorPoint) then begin
        { and the node is different from the currently hovered node }
        if HoveredGraphNode <> Node then begin
          { senpai notices you }
          HoveredGraphNode := Node;
          StatusBar.SimpleText := 'Hovered over: ' + HoveredGraphNode.Title;
        end;

        Exit;
      end;
    end;

    if HoveredGraphNode <> nil then begin
      HoveredGraphNode := nil;
      HoveredGraphWidget := nil;
      StatusBar.SimpleText := '';
    end;
  end;

how many pixels per second does a mouse typically move?

microbench rect comparison, though we are O(n)

Next step is adding widgets.

We need a way to show "pins" that graphs can connect to each other with. This takes the form of a widget, because pins/labels/etc basically are mini-widgets just like the graph editor is a widget to its parent window.

  TGraphNodeWidget = class(TObject)
    X, Y, Width, Height: Integer;
    Title: String;

    function Bounds: TRect;
  end;

  TGraphNodeWidgetList = specialize TFPGObjectList;

  TGraphNode = class(TObject)
    Title: String;
    X, Y, Width, Height: Integer;

    Widgets: TGraphNodeWidgetList;

    constructor Create;
    destructor Destroy; override;

    function Bounds: TRect;
  end;

Detecting nodes *and* widgets at point:

  procedure TForm1.GraphViewBoxMouseMove
    (Sender: TObject; Shift: TShiftState; X, Y: Integer);
  var
    Node: TGraphNode;
    Widget: TGraphNodeWidget;
    CursorPoint: TPoint;
    Bounds, Bounds2: TRect;
  begin
    CursorPoint := TPoint.Create(x, y);
    { for every node in the graph }
    for Node in Graph.nodes do begin
      Bounds := node.Bounds;
      Bounds.Offset(-GraphScrollX, -GraphScrollY);
      { if the cursor is over this node }
      if Bounds.Contains(CursorPoint) then begin
        { and the node is different from the currently hovered node }
        if HoveredGraphNode <> Node then begin
          { senpai notices you }
          HoveredGraphNode := Node;
          StatusBar.SimpleText := 'Hovered over: ' + HoveredGraphNode.Title;
        end;

        { for every widget of the noticed node }
        for Widget in Node.Widgets do begin
          Bounds2 := Widget.Bounds;
          Bounds2.Offset(Bounds.Left, Bounds.Top);

          { if the cursor is over this widget }
          if Bounds2.Contains(CursorPoint) then begin
            { and the widget is different from the currently hovered widget }
            if HoveredGraphWidget <> Widget then begin
              { senpai notices you }
              HoveredGraphWidget := Widget;
              StatusBar.SimpleText := 'Hovered over: ' + HoveredGraphNode.Title + '; widget: ' + HoveredGraphWidget.Title;
            end;
            Exit;
          end;
        end;

        { node is hovered here, but no widgets are }
        if HoveredGraphWidget <> nil then begin
          HoveredGraphWidget := nil;
          StatusBar.SimpleText := 'Hovered over: ' + HoveredGraphNode.Title;
        end;

        Exit;
      end;
    end;

    if HoveredGraphNode <> nil then begin
      HoveredGraphNode := nil;
      HoveredGraphWidget := nil;
      StatusBar.SimpleText := '';
    end;
  end;

Chief change here is this part:

  for Widget in Node.Widgets do begin
    Bounds2 := Widget.Bounds;
    Bounds2.Offset(Bounds.Left, Bounds.Top);

    if Bounds2.Contains(CursorPoint) then begin
      if HoveredGraphWidget <> Widget then begin
        HoveredGraphWidget := Widget;
        StatusBar.SimpleText := 'Hovered over: ' + HoveredGraphNode.Title + '; widget: ' + HoveredGraphWidget.Title;
      end;
      Exit;
    end;
  end;

  if HoveredGraphWidget <> nil then begin
    HoveredGraphWidget := nil;
    StatusBar.SimpleText := 'Hovered over: ' + HoveredGraphNode.Title;
  end;

  Exit;

We insert an extra check that applies only if the cursor is within a node. We then do another O(n) check for every widget on the node, and the same lazy update trigger applies for them too.

Graph nodes turn out to really just be glorified rectangles. The O(n) nature of the algorithms here bothers my OCD but it does not appear to mean much in practice. I suspect the poor performance in applications like `articy Draft` has more to do with inefficient *rendering* code.

And since code duplication is now happening (see the two sites where the status bar is set to the same format), migrate this to a `FindOnGraph` procedure. Use varying arguments so we can set the located node and widget. Since we are also going to move mouse code to separate state procedures, we can reduce the code relevant to the idle mouse state significantly.

Mouse states

To implement these I reflect back to a brief time with state machine generators. I particularly like state machine *generators* because we can have one area that is easily read, to define the states. We might also define valid transition messages somewhere. For Autobrush we are not concerned so much with allowed transitions (any state to any state is acceptable for the mouse) as we are with enforcing contracts and consistency.

For this I defer to `cog.py` and a copy of `Python 3.7`. Cog will run against the pascal source and insert generated code back to the original files, meta-programming a la `go generate`.

We pay the price of needing an extra layer of indirection to dispatch mouse events (instead of "mouse up," we read current mouse state and trampoline to "mouse up while in this state.") I don't expect this cost to mean much in practice since it's just a function call on top of a switch versus the switch statement alone. It is possible this call site could even be inlined with the right tweaks.

This adds more procedures (the exact cost of which, I'm not certain with lazarus right now) but each one is clean. It contains no code not relevant to the mouse operating against the graph in *that state*. I prefer this to the giant switch chains of SDL/Allegro code where you have to dig through a huge dispatch routine to figure out what you are doing.

There is a large block of extra code added by all this; mostly empty functions. Some are getting filled out with short bits of code to ensure the "origin" of a noodle graph operation is stored,