build method

  1. @override
Widget build(
  1. BuildContext context
)
override

Describes the part of the user interface represented by this widget.

The framework calls this method in a number of different situations. For example:

This method can potentially be called in every frame and should not have any side effects beyond building a widget.

The framework replaces the subtree below this widget with the widget returned by this method, either by updating the existing subtree or by removing the subtree and inflating a new subtree, depending on whether the widget returned by this method can update the root of the existing subtree, as determined by calling Widget.canUpdate.

Typically implementations return a newly created constellation of widgets that are configured with information from this widget's constructor, the given BuildContext, and the internal state of this State object.

The given BuildContext contains information about the location in the tree at which this widget is being built. For example, the context provides the set of inherited widgets for this location in the tree. The BuildContext argument is always the same as the context property of this State object and will remain the same for the lifetime of this object. The BuildContext argument is provided redundantly here so that this method matches the signature for a WidgetBuilder.

Design discussion

Why is the build method on State, and not StatefulWidget?

Putting a Widget build(BuildContext context) method on State rather than putting a Widget build(BuildContext context, State state) method on StatefulWidget gives developers more flexibility when subclassing StatefulWidget.

For example, AnimatedWidget is a subclass of StatefulWidget that introduces an abstract Widget build(BuildContext context) method for its subclasses to implement. If StatefulWidget already had a build method that took a State argument, AnimatedWidget would be forced to provide its State object to subclasses even though its State object is an internal implementation detail of AnimatedWidget.

Conceptually, StatelessWidget could also be implemented as a subclass of StatefulWidget in a similar manner. If the build method were on StatefulWidget rather than State, that would not be possible anymore.

Putting the build function on State rather than StatefulWidget also helps avoid a category of bugs related to closures implicitly capturing this. If you defined a closure in a build function on a StatefulWidget, that closure would implicitly capture this, which is the current widget instance, and would have the (immutable) fields of that instance in scope:

// (this is not valid Flutter code)
class MyButton extends StatefulWidgetX {
  MyButton({super.key, required this.color});

  final Color color;

  @override
  Widget build(BuildContext context, State state) {
    return SpecialWidget(
      handler: () { print('color: $color'); },
    );
  }
}

For example, suppose the parent builds MyButton with color being blue, the $color in the print function refers to blue, as expected. Now, suppose the parent rebuilds MyButton with green. The closure created by the first build still implicitly refers to the original widget and the $color still prints blue even through the widget has been updated to green; should that closure outlive its widget, it would print outdated information.

In contrast, with the build function on the State object, closures created during build implicitly capture the State instance instead of the widget instance:

class MyButton extends StatefulWidget {
  const MyButton({super.key, this.color = Colors.teal});

  final Color color;
  // ...
}

class MyButtonState extends State<MyButton> {
  // ...
  @override
  Widget build(BuildContext context) {
    return SpecialWidget(
      handler: () { print('color: ${widget.color}'); },
    );
  }
}

Now when the parent rebuilds MyButton with green, the closure created by the first build still refers to State object, which is preserved across rebuilds, but the framework has updated that State object's widget property to refer to the new MyButton instance and ${widget.color} prints green, as expected.

See also:

  • StatefulWidget, which contains the discussion on performance considerations.

Implementation

@override
Widget build(BuildContext context) {
  // DEBUG: Track build frequency
  if (SchematicPainter._debugPaintFrequency) {
    final now = DateTime.now();
    if (_lastBuildTime != null) {
      // final delta = now.difference(_lastBuildTime!).inMilliseconds; Debug
      // build logging disabled - print statements and stack trace capture are
      // expensive and impact zoom/pan performance. Re-enable only for
      // debugging. if (delta < 1000) { debugPrint('`SchematicCanvas` build
      // #$_buildCounter, delta: ${delta}ms since last build'); if
      // (_buildCounter % 10 == 0) { debugPrint('`SchematicCanvas` Stack trace
      // for build #$_buildCounter:');
      // debugPrint(StackTrace.current.toString().split('\n').take(15).join('\n'));
      //   }
      // }
    }
    _lastBuildTime = now;
  }

  // Wrap the CustomPaint in RepaintBoundary to prevent flickering on Linux.
  // This isolates the schematic rendering from other widget tree updates.
  Widget canvas = RepaintBoundary(
    child: CustomPaint(
      painter: SchematicPainter(
        layout: widget.layout,
        viewTransform: _viewTransformNotifier,
        snapshotMode: _snapshotModeNotifier,
        colorScheme: widget.colorScheme,
        highlightedWireName: _highlightedWireName,
        selectedEdgeScope: _selectedEdgeScope,
        selectedWireIds: Set.unmodifiable(_selectedWireIds),
        selectedWireScopePaths: Map.unmodifiable(_selectedWireScopePaths),
        selectedNodeIds: Set.unmodifiable(_selectedNodeIds),
        highlightedNodeId: _highlightedNodeId,
        pendingToggleNodeId: widget.pendingToggleNodeId,
        isNodeInScope: _isNodeInScope,
        hoveredBoundaryPortNotifier: _hoveredBoundaryPortNotifier,
      ),
      size: Size.infinite,
    ),
  );

  if (widget.isDimmed) {
    canvas = ColorFiltered(
      colorFilter: const ColorFilter.mode(Colors.black54, BlendMode.srcATop),
      child: canvas,
    );
  }

  return Stack(
    children: [
      RepaintBoundary(
        key: _exportBoundaryKey,
        child: Focus(
          focusNode: _focusNode,
          autofocus: true,
          onKeyEvent: (node, event) {
            if (event is KeyDownEvent) {
              // CTRL-F or CMD-F to toggle search
              if (event.logicalKey == LogicalKeyboardKey.keyF &&
                  (HardwareKeyboard.instance.isControlPressed ||
                      HardwareKeyboard.instance.isMetaPressed)) {
                final opening = !_showSearchOverlayNotifier.value;
                if (opening) {
                  _searchOverlayPosition = _currentMousePosition;
                }
                _showSearchOverlayNotifier.value = opening;
                return KeyEventResult.handled;
              }
              // When the search overlay is open, let all other keys pass
              // through to the search TextField instead of handling them
              // here (e.g. 'f' should type in the box, not fit-to-canvas).
              if (_showSearchOverlayNotifier.value) {
                return KeyEventResult.ignored;
              }
              // F key alone to fit to canvas
              if (event.logicalKey == LogicalKeyboardKey.keyF) {
                _fitToCanvas();
                return KeyEventResult.handled;
              }
            }
            return KeyEventResult.ignored;
          },
          child: MouseRegion(
            onEnter: (_) {
              // Don't steal focus from the search overlay's text field.
              if (_showSearchOverlayNotifier.value) {
                return;
              }
              if (!_focusNode.hasFocus) {
                _focusNode.requestFocus();
              }
            },
            onExit: (_) {
              // Clear tooltip when mouse leaves the schematic area
              _scheduleDismissTooltip();
              _hoverTooltipNotifier.value = (
                tooltipKey: null,
                lines: <String>[],
                position: Offset.zero,
              );
              _lastHoverCheck = null;
              _lastHoverPosition = null;
              if (_hoveredBoundaryPortId != null) {
                _setHoveredBoundaryPort(
                  portId: null,
                  nodeId: null,
                  isInterior: false,
                );
              }
            },
            onHover: (event) {
              // Always track the raw mouse position (used for search-overlay
              // anchor — must be set BEFORE the throttle early-returns).
              _currentMousePosition = event.localPosition;

              // Throttle by time AND distance to minimize edge hit-testing
              final now = DateTime.now();
              final mousePosition = event.localPosition;

              // Skip if not enough time has passed
              if (_lastHoverCheck != null) {
                final elapsed =
                    now.difference(_lastHoverCheck!).inMilliseconds;
                if (elapsed < _hoverThrottleMs) {
                  return;
                }
              }

              // Skip if mouse hasn't moved enough
              if (_lastHoverPosition != null) {
                final distance =
                    (mousePosition - _lastHoverPosition!).distance;
                if (distance < _hoverDistanceThreshold) {
                  return;
                }
              }

              _lastHoverCheck = now;
              _lastHoverPosition = mousePosition;

              final schematicPos = (mousePosition - _offset) / _scale;

              // --- 1. Check wires (edges) first ---
              // Scale-aware threshold: use a tighter distance in schematic
              // coordinates so parallel signals are distinguishable.
              final wireThreshold = 5.0 / _scale;
              for (final edge in widget.layout.edges) {
                if (_isPointNearEdge(
                  schematicPos,
                  edge,
                  threshold: wireThreshold.clamp(2.0, 15.0),
                )) {
                  var wireName = edge.wireId;
                  if (edge.signalWidth > 1) {
                    wireName = '${edge.wireId} (${edge.signalWidth})';
                  }

                  // Detect boundary port: find the closest endpoint port
                  // that belongs to an expanded/partially-expanded parent.
                  _updateHoveredBoundaryPort(edge, schematicPos);

                  final key = 'wire:$wireName';
                  final current = _hoverTooltipNotifier.value;
                  if (key != current.tooltipKey) {
                    _lastHoverScopePath = edge.scopeHierarchyPath;
                    _lastHoverAddr = edge.addr;
                    _hoverTooltipNotifier.value = (
                      tooltipKey: key,
                      lines: [wireName],
                      position: mousePosition,
                    );
                  }
                  return;
                }
              }

              // --- 1a. Port-marker proximity ---
              // The cursor isn't on a wire, but it may be hovering directly
              // over the port pin area where the boundary marker triangle
              // appears.  Activate the hover so the triangle shows up and
              // the user can click it without needing to land on the wire.
              if (_tryActivateBoundaryPortFromPortProximity(schematicPos)) {
                return;
              }

              // Clear boundary port hover when not on a wire and not near a
              // port marker.  Keep it alive if the cursor is still within the
              // marker's hit zone so the user can move from the wire to the
              // triangle to click it.
              if (_hoveredBoundaryPortId != null) {
                if (_isInsideHoveredBoundaryPortMarker(schematicPos)) {
                  // Still on the marker — keep hover state and skip further
                  // hit-testing so the tooltip doesn't flicker.
                  return;
                }
                _setHoveredBoundaryPort(
                  portId: null,
                  nodeId: null,
                  isInterior: false,
                );
              }

              // --- 1b. Check port markers (bowtie hit zones) --- Interior
              // marker → port name; exterior marker → connected wire.
              {
                final portHit = _hitTestPortMarker(schematicPos);
                if (portHit != null) {
                  final key = 'port:${portHit.$1}';
                  final current = _hoverTooltipNotifier.value;
                  if (key != current.tooltipKey) {
                    _lastHoverScopePath = portHit.$2;
                    _hoverTooltipNotifier.value = (
                      tooltipKey: key,
                      lines: [portHit.$1],
                      position: mousePosition,
                    );
                  }
                  return;
                }
              }

              // --- 1c. Check port pins on child instances (general hover)
              // --- Catches ports without bowtie markers (visible wire
              // connections).
              {
                final portPinHit = _hitTestPortPin(schematicPos);
                if (portPinHit != null) {
                  final key = 'port:${portPinHit.$1}';
                  final current = _hoverTooltipNotifier.value;
                  if (key != current.tooltipKey) {
                    _lastHoverScopePath = portPinHit.$2;
                    _hoverTooltipNotifier.value = (
                      tooltipKey: key,
                      lines: [portPinHit.$1],
                      position: mousePosition,
                    );
                  }
                  return;
                }
              }

              // --- 1d. Check external port instances (triangle stubs) ---
              // External port stubs represent the module's own I/O at the
              // boundary.  Treat them as port hovers so we get signal lookup.
              //
              // Strategy: use the hierarchy API to find the signal's full
              // instance-path.  The external port's parent in the layout is
              // the module instance; the bridge maps that to a hierarchy
              // node whose signals carry `fullPath`.  We set
              // `_lastHoverScopePath` to the directory part of that fullPath
              // so the generic tooltip code builds the correct lookup key.
              {
                SchematicInstanceData? extHit;
                var extArea = double.infinity;
                for (final inst in widget.layout.instances) {
                  if (!inst.isExternalPort) {
                    continue;
                  }
                  final rect = Rect.fromLTWH(
                    inst.x,
                    inst.y,
                    inst.width,
                    inst.height,
                  );
                  if (rect.contains(schematicPos)) {
                    final area = inst.width * inst.height;
                    if (area < extArea) {
                      extHit = inst;
                      extArea = area;
                    }
                  }
                }
                if (extHit != null) {
                  final portName = extHit.name;

                  // Find the parent module's instance path via the hierarchy
                  // bridge, then look up the signal's full path.
                  String? scope;
                  if (_bridge != null && _hierarchy != null) {
                    final parentLayoutId = widget.layout.parentMap[extHit.id];
                    if (parentLayoutId != null) {
                      final hierNodeId =
                          _bridge!.instanceIdToOccurrenceId[parentLayoutId];
                      if (hierNodeId != null) {
                        final hierAddr = OccurrenceAddress.tryFromPathname(
                          hierNodeId,
                          _hierarchy!.root,
                        );
                        final hierNode = hierAddr != null
                            ? _hierarchy!.occurrenceByAddress(hierAddr)
                            : null;
                        if (hierNode != null) {
                          // Search the module's signals for a matching port.
                          for (final sig in hierNode.signals) {
                            if (sig.name == portName) {
                              // fullPath is e.g. "top/adder0/a" — scope is
                              // the directory part "top/adder0".
                              final i = sig.path().lastIndexOf('/');
                              if (i >= 0) {
                                scope = sig.path().substring(0, i);
                              }
                              break;
                            }
                          }
                        }
                      }
                    }
                  }

                  // Fallback to old path-based scope when hierarchy lookup
                  // doesn't work (standalone path).
                  scope ??= _parentScopeOf(extHit.hierarchyPath) ??
                      extHit.hierarchyPath;

                  final key = 'port:$portName';
                  final current = _hoverTooltipNotifier.value;
                  if (key != current.tooltipKey) {
                    _lastHoverScopePath = scope;
                    _hoverTooltipNotifier.value = (
                      tooltipKey: key,
                      lines: [portName],
                      position: mousePosition,
                    );
                  }
                  return;
                }
              }

              // --- 2. Check module instances ---
              // Find the smallest (most specific) instance under cursor,
              // skipping root (parent) blocks and const blocks.
              SchematicInstanceData? hitInstance;
              var hitArea = double.infinity;
              for (final inst in widget.layout.instances) {
                final rect = Rect.fromLTWH(
                  inst.x,
                  inst.y,
                  inst.width,
                  inst.height,
                );
                if (rect.contains(schematicPos)) {
                  final area = inst.width * inst.height;
                  if (area < hitArea) {
                    hitInstance = inst;
                    hitArea = area;
                  }
                }
              }

              // For expanded/partially-expanded blocks, only show the
              // tooltip when the cursor is near the boundary rectangle —
              // not when hovering over empty space inside. Use a
              // screen-resolution-aware margin so the hit zone feels
              // consistent regardless of zoom level.
              if (hitInstance != null &&
                  (hitInstance.isExpanded ||
                      hitInstance.isPartiallyExpanded)) {
                final margin = 8.0 / _scale; // ~8 screen pixels
                final rect = Rect.fromLTWH(
                  hitInstance.x,
                  hitInstance.y,
                  hitInstance.width,
                  hitInstance.height,
                );
                final inner = rect.deflate(margin);
                // If the cursor is inside the inner rect it's not near any
                // edge, so skip the tooltip for this expanded block.
                if (inner.contains(schematicPos)) {
                  hitInstance = null;
                }
              }

              // Filter out const blocks
              if (hitInstance != null) {
                final lowerCls = hitInstance.cls.toLowerCase();
                final lowerCss = hitInstance.cssClass.toLowerCase();
                final isConst = (hitInstance.cls.isEmpty ||
                        lowerCls == 'const' ||
                        lowerCss.contains('const')) &&
                    hitInstance.name.startsWith('0x');
                if (isConst) {
                  hitInstance = null;
                }
              }

              if (hitInstance != null &&
                  !hitInstance.isExternalPort &&
                  !hitInstance.cssClass.contains('node-0')) {
                final key = 'inst:${hitInstance.id}';
                final current = _hoverTooltipNotifier.value;
                if (key != current.tooltipKey) {
                  // Count input / output ports for this instance
                  var inputs = 0;
                  var outputs = 0;
                  var inouts = 0;
                  for (final port in widget.layout.ports) {
                    if (port.instanceId == hitInstance.id) {
                      if (port.isInput) {
                        inputs++;
                      } else if (port.isOutput) {
                        outputs++;
                      } else if (port.isInout) {
                        inouts++;
                      }
                    }
                  }
                  // Prefer the original instance name for primitives
                  // (e.g. "mux_0") over the operator display name ("MUX").
                  final displayName =
                      hitInstance.instanceName ?? hitInstance.name;
                  final parentPath = _parentScopeOf(
                    hitInstance.hierarchyPath,
                  );
                  final ioSummary = 'inputs: $inputs  outputs: $outputs'
                      '${inouts > 0 ? '  inouts: $inouts' : ''}';
                  final lines = <String>[
                    displayName,
                    if (parentPath != null) 'inside $parentPath',
                    ioSummary,
                  ];
                  _hoverTooltipNotifier.value = (
                    tooltipKey: key,
                    lines: lines,
                    position: mousePosition,
                  );
                }
                return;
              }

              // --- 3. Nothing under cursor — clear tooltip ---
              final current = _hoverTooltipNotifier.value;
              if (current.tooltipKey != null) {
                _hoverTooltipNotifier.value = (
                  tooltipKey: null,
                  lines: <String>[],
                  position: Offset.zero,
                );
              }
            },
            child: Listener(
              onPointerSignal: _handlePointerSignal,
              onPointerDown: (event) {
                _pointerDownPosition = event.localPosition;
                _pointerDownButton = event.buttons;
                if (!_focusNode.hasFocus) {
                  _focusNode.requestFocus();
                }
                // Start zoom region selection if CONTROL is held
                if (_isControlPressed()) {
                  _onZoomRegionMouseDown(event.localPosition);
                }
              },
              onPointerUp: (event) async {
                if (_isSelectingZoomRegion) {
                  // Only complete zoom-to-region if the user actually
                  // dragged. A Ctrl+click (no drag) should fall through to
                  // the normal tap handler so it can do wire multi-select.
                  final distance = _pointerDownPosition != null
                      ? (event.localPosition - _pointerDownPosition!).distance
                      : double.infinity;
                  if (distance > _tapTolerance) {
                    _onZoomRegionMouseUp();
                  } else {
                    // Cancel the zoom region and handle as a normal tap.
                    _isSelectingZoomRegion = false;
                    _zoomRegionStartPoint = null;
                    _zoomRegionEndPoint = null;
                    _zoomRegionNotifier.value = (
                      startPoint: Offset.zero,
                      endPoint: Offset.zero,
                      isSelecting: false,
                    );
                    await _handleTapAtPosition(event.localPosition);
                  }
                } else if (_pointerDownPosition != null) {
                  final distance =
                      (event.localPosition - _pointerDownPosition!).distance;
                  if (distance < _tapTolerance) {
                    if (_pointerDownButton == kSecondaryMouseButton) {
                      // Right-click: show context menu
                      // if signals are selected
                      _showWireContextMenu(context, event.localPosition);
                    } else if (_pointerDownButton == 1 ||
                        _pointerDownButton == null) {
                      await _handleTapAtPosition(event.localPosition);
                    }
                  }
                }
                _pointerDownPosition = null;
                _pointerDownButton = null;
              },
              onPointerMove: (event) {
                // Update zoom region selection if in progress
                if (_isSelectingZoomRegion) {
                  _onZoomRegionMouseDrag(event.localPosition);
                } else if (_pointerDownPosition != null) {
                  final distance =
                      (event.localPosition - _pointerDownPosition!).distance;
                  if (distance > _tapTolerance) {
                    _pointerDownPosition = null;
                  }
                }
              },
              child: Stack(
                children: [
                  GestureDetector(
                    behavior: HitTestBehavior.opaque,
                    onScaleStart: _handleScaleStart,
                    onScaleUpdate: _handleScaleUpdate,
                    onScaleEnd: _handleScaleEnd,
                    child: ClipRect(child: canvas),
                  ),
                  // Zoom-to-region overlay (drawn on top).
                  ValueListenableBuilder<
                      ({
                        Offset? startPoint,
                        Offset? endPoint,
                        bool isSelecting
                      })>(
                    valueListenable: _zoomRegionNotifier,
                    builder: (context, zoomRegion, child) {
                      if (!zoomRegion.isSelecting ||
                          zoomRegion.startPoint == null ||
                          zoomRegion.endPoint == null) {
                        return const SizedBox.shrink();
                      }

                      return Positioned.fill(
                        child: IgnorePointer(
                          child: RepaintBoundary(
                            child: CustomPaint(
                              painter: SchematicZoomRegionPainter(
                                startPoint: zoomRegion.startPoint!,
                                endPoint: zoomRegion.endPoint!,
                              ),
                            ),
                          ),
                        ),
                      );
                    },
                  ),
                  // Search overlay - visible when
                  // _showSearchOverlayNotifier.value is true
                  if (_showSearchOverlayNotifier.value)
                    Positioned(
                      top: _searchOverlayTop(context),
                      left: _searchOverlayLeft(context),
                      child: Listener(
                        // Absorb pointer scroll events so they don't
                        // reach the canvas Listener and trigger zoom.
                        onPointerSignal: (event) {},
                        child: WireSearchOverlay(
                          layout: widget.layout,
                          onClose: () {
                            _showSearchOverlayNotifier.value = false;
                            _focusNode.requestFocus();
                          },
                          onWireSelected: _handleWireSearchSelection,
                          onModuleSelected: _handleModuleSearchSelection,
                          hierarchy: _hierarchy,
                        ),
                      ),
                    ),
                  // Export-to-PNG button (bottom-right corner). Hidden during
                  // snapshot capture so it doesn't appear in the PNG.
                  ValueListenableBuilder<bool>(
                    valueListenable: _snapshotModeNotifier,
                    builder: (_, isSnapshot, child) =>
                        isSnapshot ? const SizedBox.shrink() : child!,
                    child: Positioned(
                      right: 8,
                      bottom: 8,
                      child: ExportPngButton(
                        onPressed: _exportToPng,
                        tooltip: 'Export schematic as PNG',
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
      // Progress spinner shown outside the RepaintBoundary during capture.
      ValueListenableBuilder<bool>(
        valueListenable: _snapshotModeNotifier,
        builder: (_, isSnapshot, __) => isSnapshot
            ? Positioned.fill(
                child: ColoredBox(
                  color: Colors.black26,
                  child: Center(
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        const CircularProgressIndicator(),
                        const SizedBox(height: 12),
                        Text(
                          'Exporting PNG\u2026',
                          style: TextStyle(
                            color: Theme.of(context).colorScheme.onSurface,
                            fontSize: 14,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              )
            : const SizedBox.shrink(),
      ),
    ],
  );
}