Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 161 additions & 1 deletion modules/ensemble/lib/widget/text.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import 'package:ensemble/framework/action.dart' as ensemble;
import 'package:ensemble/framework/event.dart';
import 'package:ensemble/framework/extensions.dart';
import 'package:ensemble/framework/view/has_selectable_text.dart';
import 'package:ensemble/model/text_scale.dart';
import 'package:ensemble/screen_controller.dart';
import 'package:ensemble/util/utils.dart';
import 'package:ensemble/framework/widget/widget.dart' as framework;
import 'package:ensemble/widget/helpers/ColorFilter_Composite.dart';
import 'package:ensemble/widget/helpers/box_wrapper.dart';
import 'package:ensemble/widget/helpers/controllers.dart';
import 'package:ensemble/widget/helpers/widgets.dart';
import 'package:ensemble/widget/text/expandable_text.dart';
import 'package:ensemble/widget/text/span_definition.dart';
import 'package:ensemble/widget/widget_util.dart' as util;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:ensemble_ts_interpreter/invokables/invokable.dart';
import 'package:flutter_html/flutter_html.dart';
Expand Down Expand Up @@ -39,6 +44,7 @@ class EnsembleText extends StatefulWidget
'collapseLabel': () => _controller.collapseLabel,
'expandTextStyle': () => _controller.expandTextStyle,
'colorFilter': () => _controller.colorFilter,
'spans': () => _controller.spans,
};
}

Expand All @@ -65,7 +71,7 @@ class EnsembleText extends StatefulWidget
Utils.getTextStyleAsComposite(_controller, style: style),
'colorFilter': (value) =>
_controller.colorFilter = ColorFilterComposite.from( value),

'spans': (value) => _controller.spans = value,
};
}

Expand All @@ -74,6 +80,12 @@ class EnsembleText extends StatefulWidget
return {};
}

/// spans contains nested YAML (widget definitions, action definitions) that
/// must not be evaluated by the framework's auto-binding. We handle
/// expression evaluation and widget building ourselves in State.
@override
List<String> passthroughSetters() => ['spans'];

@override
EnsembleTextState createState() => EnsembleTextState();
}
Expand All @@ -91,12 +103,32 @@ class TextController extends BoxController {

ColorFilterComposite? colorFilter;

/// Raw YAML spans list. Stored as-is from the setter (passthrough) and
/// parsed into SpanDefinition objects at render time in EnsembleTextState,
/// where scopeManager is available for building widget spans.
dynamic spans;

TextStyleComposite get textStyle => _textStyle ??= TextStyleComposite(this);

set textStyle(TextStyleComposite style) => _textStyle = style;
}

class EnsembleTextState extends framework.EWidgetState<EnsembleText> {
List<TapGestureRecognizer> _tapRecognizers = [];

@override
void dispose() {
_disposeTapRecognizers();
super.dispose();
}

void _disposeTapRecognizers() {
for (final recognizer in _tapRecognizers) {
recognizer.dispose();
}
_tapRecognizers = [];
}

@override
Widget buildWidget(BuildContext context) {
return BoxWrapper(
Expand All @@ -109,6 +141,21 @@ class EnsembleTextState extends framework.EWidgetState<EnsembleText> {
final gradientStyle = controller.textStyle.gradient;
final colorFilter = controller.colorFilter;

// Spans path: when spans is provided, use rich text rendering
if (controller.spans != null) {
Widget textWidget = _buildSpansWidget(controller);
if (colorFilter?.color != null) {
textWidget = ColorFiltered(
colorFilter: colorFilter!.getColorFilter()!,
child: textWidget,
);
}
return gradientStyle != null
? _GradientText(gradient: gradientStyle, child: textWidget)
: textWidget;
}

// Existing plain text path (unchanged)
bool shouldBeSelectable = controller.selectable == true ||
(controller.selectable != false &&
context.dependOnInheritedWidgetOfExactType<HasSelectableText>() !=
Expand Down Expand Up @@ -153,6 +200,119 @@ class EnsembleTextState extends framework.EWidgetState<EnsembleText> {
: textWidget;
}

Widget _buildSpansWidget(TextController controller) {
_disposeTapRecognizers();

final spans = SpanDefinition.parseAll(controller.spans);
final bool hasWidgetSpans = spans.any((s) => s.isWidgetSpan);
final defaultStyle = controller.textStyle.getTextStyle();

List<InlineSpan> inlineSpans = [];
for (final spanDef in spans) {
if (spanDef.isTextSpan) {
inlineSpans.add(_buildTextSpan(spanDef, defaultStyle));
} else if (spanDef.isWidgetSpan) {
final widgetSpan = _buildWidgetSpan(spanDef);
if (widgetSpan != null) {
inlineSpans.add(widgetSpan);
}
}
}

// SelectableText.rich doesn't support WidgetSpan;
// fall back to Text.rich when WidgetSpans are present
bool shouldBeSelectable = !hasWidgetSpans &&
(controller.selectable == true ||
(controller.selectable != false &&
context.dependOnInheritedWidgetOfExactType<
HasSelectableText>() !=
null));

if (shouldBeSelectable) {
return SelectableText.rich(
TextSpan(
style: defaultStyle,
children: inlineSpans.cast<TextSpan>(),
),
textAlign: controller.textAlign,
maxLines: controller.maxLines,
textScaler: _getTextScaler(),
);
}

return Text.rich(
TextSpan(
style: defaultStyle,
children: inlineSpans,
),
textAlign: controller.textAlign,
maxLines: controller.maxLines,
overflow: controller.textStyle.overflow ?? TextOverflow.clip,
textScaler: _getTextScaler(),
);
}

TextSpan _buildTextSpan(SpanDefinition spanDef, TextStyle defaultStyle) {
// Evaluate text expressions (e.g., "${variable}")
String? text = spanDef.text;
if (text != null && scopeManager != null) {
try {
final evaluated = scopeManager!.dataContext.eval(text);
if (evaluated != null) {
text = evaluated.toString();
}
} catch (e) {
// Keep original text if expression evaluation fails
debugPrint('Text span expression eval failed for "$text": $e');
}
}

// Build per-span style override
TextStyle? spanStyle;
if (spanDef.textStyle != null) {
spanStyle = Utils.getTextStyle(spanDef.textStyle);
}

// Build tap recognizer if onTap is defined
TapGestureRecognizer? recognizer;
if (spanDef.onTap != null) {
final action =
ensemble.EnsembleAction.from(spanDef.onTap, initiator: widget);
if (action != null) {
recognizer = TapGestureRecognizer()
..onTap = () {
ScreenController().executeAction(
context,
action,
event: EnsembleEvent(widget),
);
};
_tapRecognizers.add(recognizer);
}
}

return TextSpan(
text: text ?? '',
style: spanStyle,
recognizer: recognizer,
);
}

WidgetSpan? _buildWidgetSpan(SpanDefinition spanDef) {
if (scopeManager == null) return null;
try {
final childWidget =
scopeManager!.buildWidgetFromDefinition(spanDef.widgetDefinition);
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: childWidget,
);
} catch (e) {
debugPrint('Failed to build widget span: $e');
return null;
}
}

TextScaler? _getTextScaler() {
if (widget.controller.textScale?.enabled == false) {
return TextScaler.noScaling;
Expand Down
59 changes: 59 additions & 0 deletions modules/ensemble/lib/widget/text/span_definition.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import 'package:ensemble/util/utils.dart';

/// Represents a single span entry from the YAML spans list.
/// Each span is either a text span or a widget span, never both.
class SpanDefinition {
/// For text spans
final String? text;
final Map<String, dynamic>? textStyle;
final dynamic onTap;

/// For widget spans
final dynamic widgetDefinition;

SpanDefinition._({
this.text,
this.textStyle,
this.onTap,
this.widgetDefinition,
});

bool get isTextSpan => text != null;
bool get isWidgetSpan => widgetDefinition != null;

/// Parse a single span entry from the YAML list.
/// Expected formats:
/// - { text: "Hello", textStyle: {...}, onTap: {...} }
/// - { widget: { Icon: { name: info, ... } } }
static SpanDefinition? from(dynamic entry) {
if (entry is! Map) return null;

if (entry.containsKey('text')) {
return SpanDefinition._(
text: Utils.optionalString(entry['text']),
textStyle: entry['textStyle'] is Map
? Map<String, dynamic>.from(entry['textStyle'])
: null,
onTap: entry['onTap'],
);
} else if (entry.containsKey('widget')) {
return SpanDefinition._(
widgetDefinition: entry['widget'],
);
}
return null;
}

/// Parse the entire spans list from raw YAML value
static List<SpanDefinition> parseAll(dynamic rawSpans) {
if (rawSpans is! List) return [];
final List<SpanDefinition> result = [];
for (final entry in rawSpans) {
final span = SpanDefinition.from(entry);
if (span != null) {
result.add(span);
}
}
return result;
}
}