-
Notifications
You must be signed in to change notification settings - Fork 143
Prototype of a2ui_core library #831
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
34408a8
22f2495
7321831
06333b4
892e4a2
890f33b
3df9da2
d73c8cc
17918f1
ca9cd96
ff788e3
633476e
b4e598b
f0fdac9
3020aeb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /// Core A2UI protocol implementation for Dart. | ||
| library a2ui_core; | ||
|
|
||
| export 'src/common/errors.dart'; | ||
| export 'src/common/cancellation.dart'; | ||
| export 'src/common/reactivity.dart'; | ||
| export 'src/common/data_path.dart'; | ||
| export 'src/protocol/catalog.dart'; | ||
| export 'src/protocol/messages.dart'; | ||
| export 'src/protocol/common.dart'; | ||
| export 'src/protocol/common_schemas.dart'; | ||
| export 'src/protocol/minimal_catalog.dart'; | ||
| export 'src/state/data_model.dart'; | ||
| export 'src/state/component_model.dart'; | ||
| export 'src/state/surface_model.dart'; | ||
| export 'src/processing/processor.dart'; | ||
| export 'src/processing/expressions.dart'; | ||
| export 'src/processing/basic_functions.dart'; | ||
| export 'src/rendering/contexts.dart'; | ||
| export 'src/rendering/binder.dart'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| /// A signal that can be used to cancel an operation. | ||
| class CancellationSignal { | ||
| bool _isCancelled = false; | ||
|
|
||
| final _listeners = <void Function()>[]; | ||
|
|
||
| /// Whether the operation has been cancelled. | ||
| bool get isCancelled => _isCancelled; | ||
|
|
||
| /// Cancels the operation. | ||
| void cancel() { | ||
| if (_isCancelled) return; | ||
| _isCancelled = true; | ||
| for (final void Function() listener in _listeners) { | ||
| listener(); | ||
| } | ||
| } | ||
|
|
||
| /// Adds a listener to be notified when the operation is cancelled. | ||
| void addListener(void Function() listener) { | ||
| if (_isCancelled) { | ||
| listener(); | ||
| } else { | ||
| _listeners.add(listener); | ||
| } | ||
| } | ||
|
|
||
| /// Removes a listener. | ||
| void removeListener(void Function() listener) { | ||
| _listeners.remove(listener); | ||
| } | ||
|
|
||
| /// Throws a [CancellationException] if the operation has been cancelled. | ||
| void throwIfCancelled() { | ||
| if (_isCancelled) { | ||
| throw const CancellationException(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// An exception thrown when an operation is cancelled. | ||
| class CancellationException implements Exception { | ||
| /// Creates a [CancellationException]. | ||
| const CancellationException([this.message]); | ||
|
|
||
| /// A message describing the cancellation. | ||
| final String? message; | ||
|
|
||
| @override | ||
| String toString() => message == null | ||
| ? 'CancellationException' | ||
| : 'CancellationException: $message'; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,83 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| /// A class for handling JSON Pointer (RFC 6901) paths. | ||||||||||||||||||||||||||||||||||||||||||||||
| class DataPath { | ||||||||||||||||||||||||||||||||||||||||||||||
| final List<String> segments; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| DataPath(this.segments); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Parses a JSON Pointer string into a [DataPath]. | ||||||||||||||||||||||||||||||||||||||||||||||
| factory DataPath.parse(String path) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (path.isEmpty || path == '/') { | ||||||||||||||||||||||||||||||||||||||||||||||
| return DataPath([]); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| String normalized = path; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (path.startsWith('/')) { | ||||||||||||||||||||||||||||||||||||||||||||||
| normalized = path.substring(1); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (normalized.endsWith('/')) { | ||||||||||||||||||||||||||||||||||||||||||||||
| normalized = normalized.substring(0, normalized.length - 1); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (normalized.isEmpty) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return DataPath([]); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| final segments = normalized.split('/').map((s) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return s.replaceAll('~1', '/').replaceAll('~0', '~'); | ||||||||||||||||||||||||||||||||||||||||||||||
| }).toList(); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| return DataPath(segments); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// The number of segments in the path. | ||||||||||||||||||||||||||||||||||||||||||||||
| int get length => segments.length; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Whether the path is empty (points to the root). | ||||||||||||||||||||||||||||||||||||||||||||||
| bool get isEmpty => segments.isEmpty; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Whether the path starts with a slash (is absolute). | ||||||||||||||||||||||||||||||||||||||||||||||
| bool get isAbsolute => true; // All parsed paths are treated as absolute for now in our context | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Joins this path with another path or segment. | ||||||||||||||||||||||||||||||||||||||||||||||
| DataPath append(dynamic other) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (other is DataPath) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return DataPath([...segments, ...other.segments]); | ||||||||||||||||||||||||||||||||||||||||||||||
| } else if (other is String) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (other.startsWith('/')) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return DataPath.parse(other); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+47
to
+49
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The behavior of To make the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||
| return DataPath([...segments, ...DataPath.parse(other).segments]); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| return DataPath([...segments, other.toString()]); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /// Returns the parent path. | ||||||||||||||||||||||||||||||||||||||||||||||
| DataPath? get parent { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (segments.isEmpty) return null; | ||||||||||||||||||||||||||||||||||||||||||||||
| return DataPath(segments.sublist(0, segments.length - 1)); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||
| String toString() { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (segments.isEmpty) return '/'; | ||||||||||||||||||||||||||||||||||||||||||||||
| return '/${segments.map((s) => s.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}'; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||
| bool operator ==(Object other) => | ||||||||||||||||||||||||||||||||||||||||||||||
| identical(this, other) || | ||||||||||||||||||||||||||||||||||||||||||||||
| other is DataPath && | ||||||||||||||||||||||||||||||||||||||||||||||
| segments.length == other.segments.length && | ||||||||||||||||||||||||||||||||||||||||||||||
| _listEquals(segments, other.segments); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||
| int get hashCode => segments.join('/').hashCode; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| static bool _listEquals(List<String> a, List<String> b) { | ||||||||||||||||||||||||||||||||||||||||||||||
| for (int i = 0; i < a.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (a[i] != b[i]) return false; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+68
to
+82
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementations of
Here is a suggested improvement using
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| /// Base class for all A2UI specific errors. | ||
| class A2uiError implements Exception { | ||
| final String message; | ||
| final String code; | ||
|
|
||
| A2uiError(this.message, [this.code = 'UNKNOWN_ERROR']); | ||
|
|
||
| @override | ||
| String toString() => '$runtimeType [$code]: $message'; | ||
| } | ||
|
|
||
| /// Thrown when JSON validation fails or schemas are mismatched. | ||
| class A2uiValidationError extends A2uiError { | ||
| final dynamic details; | ||
|
|
||
| A2uiValidationError(String message, {this.details}) : super(message, 'VALIDATION_ERROR'); | ||
| } | ||
|
|
||
| /// Thrown during DataModel mutations (invalid paths, type mismatches). | ||
| class A2uiDataError extends A2uiError { | ||
| final String? path; | ||
|
|
||
| A2uiDataError(String message, {this.path}) : super(message, 'DATA_ERROR'); | ||
| } | ||
|
|
||
| /// Thrown during string interpolation and function evaluation. | ||
| class A2uiExpressionError extends A2uiError { | ||
| final String? expression; | ||
| final dynamic details; | ||
|
|
||
| A2uiExpressionError(String message, {this.expression, this.details}) | ||
| : super(message, 'EXPRESSION_ERROR'); | ||
| } | ||
|
|
||
| /// Thrown for structural issues in the UI tree (missing surfaces, duplicate components). | ||
| class A2uiStateError extends A2uiError { | ||
| A2uiStateError(String message) : super(message, 'STATE_ERROR'); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| import 'dart:async'; | ||
|
|
||
| /// An interface for objects that maintain a list of listeners. | ||
| abstract interface class Listenable { | ||
| /// Adds a listener to be notified when the object changes. | ||
| void addListener(void Function() listener); | ||
|
|
||
| /// Removes a listener. | ||
| void removeListener(void Function() listener); | ||
| } | ||
|
|
||
| /// An interface for objects that hold a value and notify listeners when it changes. | ||
| abstract class ValueListenable<T> implements Listenable { | ||
| /// The current value. | ||
| T get value; | ||
| } | ||
|
|
||
| bool _inBatch = false; | ||
| final _pendingNotifiers = <ValueNotifier<dynamic>>{}; | ||
|
|
||
| /// Executes [callback] and defers notifications until it completes. | ||
| void batch(void Function() callback) { | ||
| if (_inBatch) { | ||
| callback(); | ||
| return; | ||
| } | ||
|
|
||
| _inBatch = true; | ||
| try { | ||
| callback(); | ||
| } finally { | ||
| _inBatch = false; | ||
| final toNotify = _pendingNotifiers.toList(); | ||
| _pendingNotifiers.clear(); | ||
| for (final notifier in toNotify) { | ||
| notifier._notifyListeners(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// A base class for objects that hold a value and notify listeners when it changes. | ||
| class ValueNotifier<T> implements ValueListenable<T> { | ||
| T _value; | ||
| final _listeners = <void Function()>{}; | ||
|
|
||
| ValueNotifier(this._value); | ||
|
|
||
| @override | ||
| T get value { | ||
| _DependencyTracker.instance?._reportRead(this); | ||
| return _value; | ||
| } | ||
|
|
||
| set value(T newValue) { | ||
| if (_value == newValue) return; | ||
| _value = newValue; | ||
| notifyListeners(); | ||
| } | ||
|
|
||
| @override | ||
| void addListener(void Function() listener) { | ||
| _listeners.add(listener); | ||
| } | ||
|
|
||
| @override | ||
| void removeListener(void Function() listener) { | ||
| _listeners.remove(listener); | ||
| } | ||
|
|
||
| /// Notifies all registered listeners. | ||
| void notifyListeners() { | ||
| if (_inBatch) { | ||
| _pendingNotifiers.add(this); | ||
| return; | ||
| } | ||
| _notifyListeners(); | ||
| } | ||
|
|
||
| void _notifyListeners() { | ||
| for (final listener in List<void Function()>.from(_listeners)) { | ||
| if (_listeners.contains(listener)) { | ||
| listener(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Disposes of the notifier and its resources. | ||
| void dispose() { | ||
| _listeners.clear(); | ||
| _pendingNotifiers.remove(this); | ||
| } | ||
| } | ||
|
|
||
| /// A derived notifier that automatically tracks and listens to other [ValueListenable] | ||
| /// dependencies, recalculating its value only when they change. | ||
| class ComputedNotifier<T> extends ValueNotifier<T> { | ||
| final T Function() _compute; | ||
| final Set<ValueListenable<dynamic>> _dependencies = {}; | ||
|
|
||
| ComputedNotifier(this._compute) : super(_compute()) { | ||
| _updateDependencies(); | ||
| } | ||
|
|
||
| void _updateDependencies() { | ||
| final tracker = _DependencyTracker(); | ||
| final newValue = tracker.track(_compute); | ||
|
|
||
| final newDeps = tracker.dependencies; | ||
|
|
||
| // Unsubscribe from old dependencies no longer needed | ||
| for (final dep in _dependencies.difference(newDeps)) { | ||
| dep.removeListener(_onDependencyChanged); | ||
| } | ||
|
|
||
| // Subscribe to new dependencies | ||
| for (final dep in newDeps.difference(_dependencies)) { | ||
| dep.addListener(_onDependencyChanged); | ||
| } | ||
|
|
||
| _dependencies.clear(); | ||
| _dependencies.addAll(newDeps); | ||
|
|
||
| super.value = newValue; | ||
| } | ||
|
|
||
| void _onDependencyChanged() { | ||
| _updateDependencies(); | ||
| } | ||
|
|
||
| @override | ||
| T get value { | ||
| // If we have no listeners, we might want to re-evaluate on every read | ||
| // to ensure we're fresh, but usually, Computed is used with listeners. | ||
| // For now, let's just return the cached value and rely on dependencies. | ||
| return super.value; | ||
| } | ||
|
|
||
| @override | ||
| void dispose() { | ||
| for (final dep in _dependencies) { | ||
| dep.removeListener(_onDependencyChanged); | ||
| } | ||
| _dependencies.clear(); | ||
| super.dispose(); | ||
| } | ||
| } | ||
|
|
||
| class _DependencyTracker { | ||
| static _DependencyTracker? instance; | ||
| final Set<ValueListenable<dynamic>> dependencies = {}; | ||
|
|
||
| T track<T>(T Function() callback) { | ||
| final previous = instance; | ||
| instance = this; | ||
| try { | ||
| return callback(); | ||
| } finally { | ||
| instance = previous; | ||
| } | ||
| } | ||
|
|
||
| void _reportRead(ValueListenable<dynamic> listenable) { | ||
| dependencies.add(listenable); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
cancelmethod has a potential forConcurrentModificationErrorif a listener tries to modify the listeners list while it's being iterated over. It's safer to iterate over a copy of the list.Also, once a signal is cancelled, the listeners are no longer needed and can be cleared to free memory.
For managing listeners, consider using a
Setinstead of aListfor_listenersto automatically prevent duplicate registrations. This is a common practice for listener patterns in Dart.