Skip to content

Architecture

The Vyuh Dashboard Editor follows a layered architecture with a reactive MobX-based engine at its core, a command pattern for all mutations, and a pluggable extension system for cross-cutting concerns.

System Overview

Layered Architecture

DashboardEngine

The DashboardEngine is the core state kernel. It owns the reactive layout, executes commands, and emits events to extensions. It has no knowledge of UI, persistence, or history -- those are handled by extensions.

dart
class DashboardEngine implements DashboardEngineApi {
  // Reactive layout state
  late final Observable<DashboardLayout> layoutObservable;
  DashboardLayout get layout => layoutObservable.value;

  // Extension management
  void addExtension(DashboardExtension extension);
  void removeExtension(String id);
  E? getExtension<E extends DashboardExtension>();

  // Command execution
  Future<void> executeCommand(DashboardCommand command);
  Future<void> applyUndo(DashboardCommand command);
  Future<void> applyRedo(DashboardCommand command);

  // Direct layout replacement (for persistence loads)
  void replaceLayout(DashboardLayout newLayout);

  // Event emission
  void emitEvent(DashboardEvent event);

  // Batch operations
  void batch(String reason, void Function() operations);
}

Command Execution Flow

When the engine executes a command, it follows this precise sequence:

All mutations happen inside a MobX runInAction, which ensures that UI rebuilds are batched. The engine emits two events per command execution: DashboardLayoutChanged (with previous and current layout) and DashboardCommandExecuted (with the command itself).

Engine API

The engine exposes a high-level mutation API that creates and executes commands internally:

MethodCommand CreatedDescription
addRow()AddRowCommandAdd a row with a single empty column
removeRow(rowId)RemoveRowCommandRemove a row and all its columns
reorderRow(old, new)ReorderRowsCommandMove a row to a new position
splitRow(rowId, n)SplitRowCommandReplace columns with n equal columns
addColumn(rowId)AddColumnCommandAdd an empty column to a row
removeColumn(rowId, colId)RemoveColumnCommandRemove a column from a row
reorderColumns(rowId, old, new)ReorderColumnsCommandReorder columns within a row
updateColumnFlex(...)UpdateColumnFlexCommandChange a single column's flex
resizeAdjacentColumns(...)ResizeAdjacentColumnsCommandResize two adjacent columns together
updateRowFlex(...)UpdateRowFlexCommandUpdate flex for all columns in a row
updateRowTitle(...)UpdateRowTitleCommandChange or clear row title
updateRowHeight(...)UpdateRowHeightCommandSet row height in pixels
updateRowViewportFraction(...)UpdateRowViewportFractionCommandSet viewport-relative row height
addComponent(...)AddComponentCommandPlace a component in a column
removeComponent(...)RemoveComponentCommandClear a column's component
replaceComponent(...)ReplaceComponentCommandReplace one component with another
swapComponents(...)SwapComponentsCommandSwap components between two columns
duplicateComponent(...)DuplicateComponentCommandCopy a component to the nearest empty slot
updateComponentConfig(...)UpdateComponentConfigCommandUpdate a component's properties
updateLayoutMetadata(...)UpdateDashboardMetadataCommandPatch dashboard-level metadata

Command Pattern

Every mutation is a DashboardCommand that implements two methods:

dart
abstract class DashboardCommand {
  DashboardLayout execute();  // Apply the mutation
  DashboardLayout undo();     // Reverse the mutation
  String get description;     // Human-readable label
}

Commands are pure functions from layout to layout. They capture the current layout in their constructor and return a new layout from execute() and undo(). This makes them trivially testable:

dart
final command = AddRowCommand(
  layout: currentLayout,
  rowToAdd: newRow,
  position: 0,
);

final afterExecute = command.execute();
expect(afterExecute.rows.length, currentLayout.rows.length + 1);

final afterUndo = command.undo();
expect(afterUndo.rows.length, currentLayout.rows.length);

Command Categories

The 20 built-in commands fall into four categories:

Row commands: AddRowCommand, RemoveRowCommand, ReorderRowsCommand, SplitRowCommand, UpdateRowTitleCommand, UpdateRowHeightCommand, UpdateRowViewportFractionCommand

Column commands: AddColumnCommand, RemoveColumnCommand, ReorderColumnsCommand, UpdateColumnFlexCommand, ResizeAdjacentColumnsCommand, UpdateRowFlexCommand

Component commands: AddComponentCommand, RemoveComponentCommand, ReplaceComponentCommand, SwapComponentsCommand, DuplicateComponentCommand, UpdateComponentConfigCommand

Dashboard commands: UpdateDashboardMetadataCommand

CommandHistory

The CommandHistory class manages the undo/redo stack:

dart
class CommandHistory {
  final int maxHistorySize;  // Default: 50

  void execute(DashboardCommand command);  // Add to stack
  DashboardCommand? undo();   // Move back
  DashboardCommand? redo();   // Move forward
  void clear();

  bool get canUndo;
  bool get canRedo;
  int get historySize;
  int get currentPosition;
}

When a new command is executed after an undo, the redo stack is discarded (standard undo/redo behavior). The history has a configurable maximum size (default 50) to prevent unbounded memory growth.

Event System

The engine communicates with extensions through structured events:

dart
abstract class DashboardEvent {
  const DashboardEvent();
}

// Emitted on every layout change
class DashboardLayoutChanged extends DashboardEvent {
  final DashboardLayout previous;
  final DashboardLayout current;
}

// Emitted when a command executes
class DashboardCommandExecuted extends DashboardEvent {
  final DashboardCommand command;
}

// Emitted when a command is undone
class DashboardCommandUndone extends DashboardEvent {
  final DashboardCommand command;
}

// Emitted when a command is redone
class DashboardCommandRedone extends DashboardEvent {
  final DashboardCommand command;
}

// Batch boundaries
class DashboardBatchStarted extends DashboardEvent {
  final String reason;
}
class DashboardBatchEnded extends DashboardEvent {}

Events are dispatched synchronously to all registered extensions in registration order. This means extensions can rely on seeing events in a consistent sequence.

Extension System

Extensions augment the engine without modifying its core. Each extension implements the DashboardExtension interface:

dart
abstract class DashboardExtension {
  String get id;                          // Unique identifier
  void attach(DashboardEngineApi engine); // Called when added
  void detach();                          // Called when removed
  void onEvent(DashboardEvent event);     // React to events
}

The DashboardEngineApi is a forward declaration that provides extensions with access to the layout, command execution, and event emission without creating circular dependencies.

Built-in Extensions

ExtensionIDPurpose
DashboardHistoryExtensiondashboard-historyUndo/redo via CommandHistory
DashboardPersistenceExtensiondashboard-persistenceLayout JSON serialization, direct layout load, dirty tracking
DashboardSelectionExtensiondashboard-selectionCell, row, and component selection state
DashboardAnalyticsExtensiondashboard-analyticsTrack component additions and editor actions

Extension Event Handling

Each extension listens for specific events:

  • History listens for DashboardCommandExecuted to record commands in the stack.
  • Persistence listens for DashboardCommandExecuted, DashboardCommandUndone, and DashboardCommandRedone to set the dirty flag.
  • Selection listens for DashboardLayoutChanged to validate that selected rows/columns still exist.
  • Analytics listens for DashboardCommandExecuted to track component additions.

DashboardEditorController

The DashboardEditorController is a facade that composes the engine with all four extensions and provides the complete editing API:

dart
class DashboardEditorController {
  final DashboardEngine engine;

  // Extensions
  late final DashboardHistoryExtension history;
  late final DashboardPersistenceExtension persistence;
  late final DashboardSelectionExtension selection;
  late final DashboardAnalyticsExtension analytics;

  // Observable state
  Observable<DashboardLayout> get layout;
  Observable<bool> get isDirty;
  Observable<String?> get selectedCellRowId;
  Observable<String?> get selectedCellColumnId;

  // Layout mutations (delegates to engine)
  Future<String> addRow({int? position});
  Future<void> removeRow(String rowId);
  Future<void> splitRow(String rowId, int numColumns);
  // ... all engine methods

  // Selection
  void selectCell(String rowId, String columnId);
  void clearSelection();
  void navigateCell({int dx, int dy});

  // Undo/redo
  Future<void> undo();
  Future<void> redo();

  // Persistence / serialization
  Map<String, dynamic> toJson();
  void fromJson(Map<String, dynamic> json);
  void loadLayout(DashboardLayout newLayout);
  void markClean();
  void markDirty();
}

The controller also manages editor-specific draft state for the config panel, ensuring property edits are committed back to the layout through UpdateComponentConfigCommand rather than mutating the component instance in place.

Next Steps