update method

  1. @override
(Model, Cmd?) update(
  1. Msg msg
)
override

Handles a message and returns the new model state and optional command.

This is the heart of the Elm Architecture. When a message arrives (from user input, timers, async operations, etc.), this method:

  1. Examines the message
  2. Computes the new model state
  3. Optionally returns a command to execute

The returned tuple contains:

  • The new model (can be this if unchanged)
  • An optional command to execute (or null)

Pattern Matching

Dart's pattern matching makes update functions clean and readable:

@override
(Model, Cmd?) update(Msg msg) {
  return switch (msg) {
    // Match key type
    KeyMsg(key: Key(type: KeyType.enter)) =>
      (submitForm(), null),

    // Match specific character
    KeyMsg(key: Key(type: KeyType.runes, runes: [0x71])) =>
      (this, Cmd.quit()),

    // Match with modifier
    KeyMsg(key: Key(ctrl: true, runes: [0x73])) => // Ctrl+S
      (this, saveFile()),

    // Match custom message with destructuring
    DataLoadedMsg(:final items) =>
      (copyWith(items: items, loading: false), null),

    // Match window resize
    WindowSizeMsg(:final width, :final height) =>
      (copyWith(width: width, height: height), null),

    // Default case - no change
    _ => (this, null),
  };
}

Immutability

Models should be immutable. Create new instances rather than modifying existing ones:

// ✓ Good - create new instance
return (CounterModel(count + 1), null);

// ✓ Good - use copyWith pattern
return (copyWith(count: count + 1), null);

// ✗ Bad - mutating state
count++;
return (this, null);

Implementation

@override
(Model, Cmd?) update(Msg msg) {
  final cmds = <Cmd>[];

  if (TuiTrace.enabled) {
    TuiTrace.log('widget_app.update start ${msg.runtimeType}');
  }

  if (msg is KeyMsg) {
    _recordKeyTimestamp();
  }

  if (msg is FrameTickMsg) {
    if (_debugOverlayEnabled) {
      _overlayDirty = true;
    }
    if (!handleFrameTick) {
      return (this, null);
    }
  }

  // --- F12 toggles the built-in debug overlay ---
  if (msg is KeyMsg && msg.key.type == KeyType.f12) {
    _debugOverlayEnabled = !_debugOverlayEnabled;
    _runtimeDebugOverlay = _runtimeDebugOverlay.setEnabled(
      _debugOverlayEnabled,
    );
    _runtimeDebugOverlay = _positionRuntimeOverlay(_runtimeDebugOverlay);
    _overlayDirty = true;
    return (this, null);
  }

  if (msg is _RenderMetricsInjectionMsg) {
    _applyRenderMetricsInjection(msg.injection);
    return (this, null);
  }

  // Store runtime render metrics but avoid forcing full-tree rebuilds for
  // WidgetApp's built-in overlay. We compose that overlay outside the tree
  // from a cached base view (split-dashboard style).
  if (msg is RenderMetricsMsg) {
    _applyRenderMetricsInjection(
      RenderMetricsInjection(metrics: msg.metrics),
    );
    return (this, null);
  }

  if (msg is WindowSizeMsg) {
    _tree.setRootConstraints(
      BoxConstraints.tight(Size(msg.width.toDouble(), msg.height.toDouble())),
    );
    _mediaQueryData = MediaQueryData(
      size: Size(msg.width.toDouble(), msg.height.toDouble()),
    );
    _tree.update(
      _MediaQueryHost(
        key: _mediaQueryKey,
        data: _mediaQueryData,
        metricsHolder: _metricsHolder,
        child: _currentRoot(),
      ),
    );
    _runtimeDebugOverlay = _runtimeDebugOverlay.copyWith(
      terminalWidth: msg.width,
      terminalHeight: msg.height,
    );
    _runtimeDebugOverlay = _positionRuntimeOverlay(_runtimeDebugOverlay);
    if (_debugOverlayEnabled) {
      _overlayDirty = true;
    }
    _dirty = true;
  }

  if (msg is MouseMsg) {
    // --- Mouse capture: active drag/press owner gets the event first ---
    final capture = _tree.mouseCapture;
    if (capture != null) {
      final Stopwatch? dispatchSw = TuiTrace.enabled ? Stopwatch() : null;
      dispatchSw?.start();
      final cmd = _tree.dispatchTo(capture, msg);
      dispatchSw?.stop();
      if (TuiTrace.captureDispatchEnabled) {
        TuiTrace.log(
          'widget_app.capture_dispatch ${capture.widget.runtimeType} '
          'dt=${dispatchSw?.elapsedMicroseconds ?? -1}us',
        );
      }
      if (cmd != null) cmds.add(cmd);

      root = _currentRoot();
      _dirty = _dirty || _tree.hasDirty || _tree.hasPaintDirty;
      if (TuiTrace.enabled) {
        TuiTrace.log('widget_app.update end (capture) dirty=$_dirty');
      }
      return (this, _coalesceCommands(cmds));
    }

    // --- Render-tree hit-testing (default for widget apps) ---
    if (useHitTesting) {
      final Stopwatch? hitSw = TuiTrace.enabled ? Stopwatch() : null;
      hitSw?.start();
      final hits = _tree.hitTestAt(msg.x.toDouble(), msg.y.toDouble());
      hitSw?.stop();
      if (TuiTrace.enabled) {
        TuiTrace.log(
          'widget_app.hitTest count=${hits.length} '
          'mouse=(${msg.x},${msg.y}) '
          'dt=${hitSw?.elapsedMicroseconds ?? -1}us',
        );
      }
      if (hits.isNotEmpty) {
        // Dispatch a HitTestMouseMsg starting from the deepest hit element,
        // bubbling UP to ancestors.  This ensures StatefulWidgets like
        // GestureDetector (which have no render object of their own)
        // receive the event when their child's render object is hit.
        //
        // Track which StatefulElements have already been visited so that
        // when multiple hit entries (child render objects) bubble up to
        // the same GestureDetector, it only processes the event once.
        final visited = <Element>{};
        for (final hit in hits) {
          final hitMsg = HitTestMouseMsg(
            event: msg,
            localX: hit.localX,
            localY: hit.localY,
          );
          final cmd = _tree.dispatchBubbleUp(
            hit.element,
            hitMsg,
            visited: visited,
          );
          if (cmd != null) {
            cmds.add(cmd);
            break;
          }
        }
        // Capture dirty state BEFORE broadcasting the raw MouseMsg.
        // The hit-test dispatch above may have triggered setState() calls
        // (e.g., onWheel, onEnter) that marked elements dirty.  If we
        // broadcast the raw MouseMsg first, _tree.dispatch() rebuilds
        // those dirty elements, clearing hasDirty/hasPaintDirty before we
        // can propagate it to `_dirty`.
        root = _currentRoot();
        _dirty = _dirty || _tree.hasDirty || _tree.hasPaintDirty;

        // Broadcast raw mouse only when needed for out-of-hit housekeeping
        // (hover-exit and selection-finalize outside bounds). Avoid doing
        // this for wheel/press to prevent whole-tree traversals during
        // scroll bursts.
        final isWheelLike =
            msg.action == MouseAction.wheel ||
            msg.button == MouseButton.wheelUp ||
            msg.button == MouseButton.wheelDown ||
            msg.button == MouseButton.wheelLeft ||
            msg.button == MouseButton.wheelRight;
        // Press events are already delivered through hit-test bubbling.
        // Re-broadcasting press globally can let unrelated widgets react to
        // the same click (and potentially steal mouse capture), which breaks
        // controls like draggable scroll thumbs.
        final shouldBroadcastRawMouse =
            !isWheelLike && msg.action != MouseAction.press;
        if (shouldBroadcastRawMouse) {
          final broadcastCmd = _tree.dispatch(msg);
          if (broadcastCmd != null) cmds.add(broadcastCmd);
        }

        // Pick up any additional dirty flags from the broadcast.
        root = _currentRoot();
        _dirty = _dirty || _tree.hasDirty || _tree.hasPaintDirty;
        if (TuiTrace.enabled) {
          TuiTrace.log('widget_app.update end (hitTest) dirty=$_dirty');
        }
        return (this, _coalesceCommands(cmds));
      }
    }
  }

  // Capture dirty state before dispatch — external setState() calls
  // (e.g., from OverlayEntry.markNeedsBuild) mark elements dirty between
  // frames.  dispatch() rebuilds them, clearing hasDirty, so we must
  // record the flag beforehand to ensure view() invalidates the cache.
  final hadDirtyBeforeDispatch = _tree.hasDirty || _tree.hasPaintDirty;

  final Stopwatch? dispatchSw = TuiTrace.enabled ? Stopwatch() : null;
  dispatchSw?.start();
  final cmd = _tree.dispatch(msg);
  dispatchSw?.stop();
  if (TuiTrace.enabled) {
    TuiTrace.log(
      'widget_app.dispatch ${msg.runtimeType} '
      'dt=${dispatchSw?.elapsedMicroseconds ?? -1}us',
    );
  }
  if (cmd != null) cmds.add(cmd);

  root = _currentRoot();
  if (msg is WindowSizeMsg || msg is BackgroundColorMsg) {
    _dirty = true;
  } else {
    _dirty =
        _dirty ||
        hadDirtyBeforeDispatch ||
        _tree.hasDirty ||
        _tree.hasPaintDirty;
  }

  if (TuiTrace.enabled) {
    TuiTrace.log('widget_app.update end dirty=$_dirty');
  }

  return (this, _coalesceCommands(cmds));
}